chromium/chrome/browser/notifications/win/notification_template_builder.cc

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/notifications/win/notification_template_builder.h"

#include <algorithm>
#include <string>
#include <string_view>
#include <vector>

#include "base/files/file_path.h"
#include "base/i18n/time_formatting.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "chrome/browser/notifications/win/notification_launch_id.h"
#include "chrome/common/chrome_features.h"
#include "chrome/common/notifications/notification_image_retainer.h"
#include "chrome/grit/branded_strings.h"
#include "chrome/grit/generated_resources.h"
#include "components/url_formatter/elide_url.h"
#include "third_party/icu/source/i18n/unicode/timezone.h"
#include "third_party/libxml/chromium/xml_writer.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/message_center/public/cpp/notification.h"
#include "ui/strings/grit/ui_strings.h"
#include "url/gurl.h"
#include "url/origin.h"

namespace {

// The different types of text nodes to output.
enum class TextType { NORMAL, ATTRIBUTION };

// Label to override context menu items in tests.
const char* context_menu_label_override = nullptr;

// Constants used for the XML element names and their attributes.
const char kActionElement[] = "action";
const char kActionsElement[] = "actions";
const char kActivationType[] = "activationType";
const char kArguments[] = "arguments";
const char kAttribution[] = "attribution";
const char kAudioElement[] = "audio";
const char kBackground[] = "background";
const char kBindingElement[] = "binding";
const char kBindingElementTemplateAttribute[] = "template";
const char kContent[] = "content";
const char kContextMenu[] = "contextMenu";
const char kCritical[] = "Critical";
const char kDuration[] = "duration";
const char kDurationLong[] = "long";
const char kForeground[] = "foreground";
const char kHero[] = "hero";
const char kHintButtonStyle[] = "hint-buttonStyle";
const char kHintCrop[] = "hint-crop";
const char kHintCropNone[] = "none";
const char kImageElement[] = "image";
const char kImageUri[] = "imageUri";
const char kIncomingCall[] = "incomingCall";
const char kIndeterminate[] = "indeterminate";
const char kInputElement[] = "input";
const char kInputId[] = "id";
const char kInputType[] = "type";
const char kStatus[] = "status";
const char kPlaceholderContent[] = "placeHolderContent";
const char kPlacement[] = "placement";
const char kPlacementAppLogoOverride[] = "appLogoOverride";
const char kProgress[] = "progress";
const char kReminder[] = "reminder";
const char kScenario[] = "scenario";
const char kSilent[] = "silent";
const char kSrc[] = "src";
const char kSuccess[] = "Success";
const char kText[] = "text";
const char kTextElement[] = "text";
const char kToastElementDisplayTimestamp[] = "displayTimestamp";
const char kTrue[] = "true";
const char kUseButtonStyle[] = "useButtonStyle";
const char kUserResponse[] = "userResponse";
const char kValue[] = "value";
const char kVisualElement[] = "visual";

// Name of the template used for default Chrome notifications.
const char kDefaultTemplate[] = "ToastGeneric";

// The XML version header that has to be stripped from the output.
const char kXmlVersionHeader[] = "<?xml version=\"1.0\"?>\n";

// Formats the |origin| for display in the notification template.
std::string FormatOrigin(const GURL& origin) {
  std::u16string origin_string = url_formatter::FormatOriginForSecurityDisplay(
      url::Origin::Create(origin),
      url_formatter::SchemeDisplay::OMIT_HTTP_AND_HTTPS);
  DCHECK(origin_string.size());

  return base::UTF16ToUTF8(origin_string);
}

// Writes the <toast> element with a given |launch_attribute|.
// Also closes the |xml_writer_| for writing as the toast is now complete.
void StartToastElement(XmlWriter* xml_writer,
                       const NotificationLaunchId& launch_id,
                       const message_center::Notification& notification) {
  xml_writer->StartElement(kNotificationToastElement);
  xml_writer->AddAttribute(kNotificationLaunchAttribute, launch_id.Serialize());

  // Only notifications created by an installed web app should be allowed to
  // have increased priority, colored buttons, and a ringtone.
  if (notification.scenario() ==
      message_center::NotificationScenario::INCOMING_CALL) {
    xml_writer->AddAttribute(kScenario, kIncomingCall);
    xml_writer->AddAttribute(kUseButtonStyle, kTrue);
  } else if (notification.never_timeout()) {
    if (base::FeatureList::IsEnabled(
            features::kNotificationDurationLongForRequireInteraction)) {
      xml_writer->AddAttribute(kDuration, kDurationLong);
    } else {
      // Note: If the notification doesn't include a button, then Windows will
      // ignore the Reminder flag. See EnsureReminderHasButton below.
      xml_writer->AddAttribute(kScenario, kReminder);
    }
  }

  if (notification.timestamp().is_null())
    return;

  xml_writer->AddAttribute(
      kToastElementDisplayTimestamp,
      base::UnlocalizedTimeFormatWithPattern(notification.timestamp(),
                                             "yyyy-MM-dd'T'HH:mm:ssX",
                                             icu::TimeZone::getGMT()));
}

void EndToastElement(XmlWriter* xml_writer) {
  xml_writer->EndElement();
}

// Writes the <visual> element.
void StartVisualElement(XmlWriter* xml_writer) {
  xml_writer->StartElement(kVisualElement);
}

void EndVisualElement(XmlWriter* xml_writer) {
  xml_writer->EndElement();
}

// Writes the <binding> element with the given |template_name|.
void StartBindingElement(XmlWriter* xml_writer,
                         const std::string& template_name) {
  xml_writer->StartElement(kBindingElement);
  xml_writer->AddAttribute(kBindingElementTemplateAttribute, template_name);
}

void EndBindingElement(XmlWriter* xml_writer) {
  xml_writer->EndElement();
}

// Writes the <text> element with the given |content|. If |text_type| is
// ATTRIBUTION then |content| is treated as the source that the notification is
// attributed to.
void WriteTextElement(XmlWriter* xml_writer,
                      const std::string& content,
                      TextType text_type) {
  xml_writer->StartElement(kTextElement);
  if (text_type == TextType::ATTRIBUTION)
    xml_writer->AddAttribute(kPlacement, kAttribution);
  xml_writer->AppendElementContent(content);
  xml_writer->EndElement();
}

// Writes the <text> element containing the list entries.
void WriteItems(XmlWriter* xml_writer,
                const std::vector<message_center::NotificationItem>& items) {
  // A toast can have a maximum of three text items, of which one is reserved
  // for the title. The remaining two can each handle up to four lines of text,
  // but the toast can only show four lines total, so there's no point in having
  // more than one text item. Therefore, we show them all in one and hope there
  // is no truncation at the bottom. There will never be room for items 5 and up
  // so we don't make an attempt to show them.
  constexpr size_t kMaxEntries = 4;
  size_t entries = std::min(kMaxEntries, items.size());
  std::string item_list;
  for (size_t i = 0; i < entries; ++i) {
    const auto& item = items[i];
    item_list += base::UTF16ToUTF8(item.title()) + " - " +
                 base::UTF16ToUTF8(item.message()) + "\n";
  }
  WriteTextElement(xml_writer, item_list, TextType::NORMAL);
}

// A helper for constructing image xml.
void WriteImageElement(XmlWriter* xml_writer,
                       NotificationImageRetainer* image_retainer,
                       const gfx::Image& image,
                       const std::string& placement,
                       const std::string& hint_crop) {
  base::FilePath path = image_retainer->RegisterTemporaryImage(image);
  if (!path.empty()) {
    xml_writer->StartElement(kImageElement);
    xml_writer->AddAttribute(kPlacement, placement);
    xml_writer->AddAttribute(kSrc, base::WideToUTF8(path.value()));
    if (!hint_crop.empty())
      xml_writer->AddAttribute(kHintCrop, hint_crop);
    xml_writer->EndElement();
  }
}

// Writes the <image> element for the notification icon.
void WriteIconElement(XmlWriter* xml_writer,
                      NotificationImageRetainer* image_retainer,
                      const message_center::Notification& notification) {
  WriteImageElement(xml_writer, image_retainer,
                    gfx::Image(notification.icon().Rasterize(nullptr)),
                    kPlacementAppLogoOverride, kHintCropNone);
}

// Writes the <image> element for showing a large image within the notification
// body.
void WriteLargeImageElement(XmlWriter* xml_writer,
                            NotificationImageRetainer* image_retainer,
                            const message_center::Notification& notification) {
  WriteImageElement(xml_writer, image_retainer, notification.image(), kHero,
                    std::string());
}

// Adds a progress bar to the notification XML.
void WriteProgressElement(XmlWriter* xml_writer,
                          const message_center::Notification& notification) {
  // Two other attributes are supported by Microsoft:
  // title: A string shown on the left side of the toast, just above the bar.
  // valueStringOverride: A string that replaces the percentage on the right.
  xml_writer->StartElement(kProgress);
  // Status is mandatory, without it the progress bar is not shown.
  xml_writer->AddAttribute(kStatus,
                           base::UTF16ToUTF8(notification.progress_status()));

  // Show indeterminate spinner for values outside the [0-100] range.
  if (notification.progress() < 0 || notification.progress() > 100) {
    xml_writer->AddAttribute(kValue, kIndeterminate);
  } else {
    double value = 1.0 * notification.progress() / 100;
    xml_writer->AddAttribute(kValue, base::StringPrintf("%3.2f", value));
  }

  xml_writer->EndElement();
}

// Writes the <actions> element.
void StartActionsElement(XmlWriter* xml_writer) {
  xml_writer->StartElement(kActionsElement);
}

void EndActionsElement(XmlWriter* xml_writer) {
  xml_writer->EndElement();
}

// A helper for constructing action xml.
void WriteActionElement(XmlWriter* xml_writer,
                        NotificationImageRetainer* image_retainer,
                        const message_center::ButtonInfo& button,
                        int index,
                        NotificationLaunchId copied_launch_id) {
  xml_writer->StartElement(kActionElement);

  // All notifications buttons in the incoming-call scenario should be green,
  // except for the default dismiss button added by Chromium (not by the Action
  // Center), which should be red. This attribute will take effect only if the
  // 'useButtonStyle' attribute has been added to the toast XML element - i.e.,
  // when the notification scenario is INCOMING_CALL.
  if (button.type == message_center::ButtonType::DISMISS) {
    xml_writer->AddAttribute(kActivationType, kBackground);
    copied_launch_id.set_is_for_dismiss_button();
    xml_writer->AddAttribute(kHintButtonStyle, kCritical);
  } else {
    xml_writer->AddAttribute(kActivationType, kForeground);
    copied_launch_id.set_button_index(index);
    if (button.type == message_center::ButtonType::ACKNOWLEDGE) {
      xml_writer->AddAttribute(kHintButtonStyle, kSuccess);
    }
  }

  xml_writer->AddAttribute(kContent, base::UTF16ToUTF8(button.title));
  xml_writer->AddAttribute(kArguments, copied_launch_id.Serialize());

  if (!button.icon.IsEmpty()) {
    base::FilePath path = image_retainer->RegisterTemporaryImage(button.icon);
    if (!path.empty())
      xml_writer->AddAttribute(kImageUri, path.AsUTF8Unsafe());
  }

  xml_writer->EndElement();
}

// Fills in the details for the actions (the buttons the notification contains).
void AddActions(XmlWriter* xml_writer,
                NotificationImageRetainer* image_retainer,
                const message_center::Notification& notification,
                const NotificationLaunchId& launch_id) {
  const std::vector<message_center::ButtonInfo>& buttons =
      notification.buttons();
  bool inline_reply = false;
  std::string placeholder;
  for (const auto& button : buttons) {
    if (!button.placeholder)
      continue;

    inline_reply = true;
    placeholder = base::UTF16ToUTF8(*button.placeholder);
    break;
  }

  if (inline_reply) {
    xml_writer->StartElement(kInputElement);
    xml_writer->AddAttribute(kInputId, kUserResponse);
    xml_writer->AddAttribute(kInputType, kText);
    xml_writer->AddAttribute(kPlaceholderContent, placeholder);
    xml_writer->EndElement();
  }

  for (size_t i = 0; i < buttons.size(); ++i) {
    WriteActionElement(xml_writer, image_retainer, buttons[i], i, launch_id);
  }
}

// Writes the <audio silent="true"> element.
void WriteAudioSilentElement(XmlWriter* xml_writer) {
  xml_writer->StartElement(kAudioElement);
  xml_writer->AddAttribute(kSilent, kTrue);
  xml_writer->EndElement();
}

// A helper for constructing context menu xml.
void WriteContextMenuElement(XmlWriter* xml_writer,
                             const std::string& content,
                             const std::string& arguments) {
  xml_writer->StartElement(kActionElement);
  xml_writer->AddAttribute(kContent, content);
  xml_writer->AddAttribute(kPlacement, kContextMenu);
  xml_writer->AddAttribute(kActivationType, kForeground);
  xml_writer->AddAttribute(kArguments, arguments);
  xml_writer->EndElement();
}

// Adds context menu actions to the notification.
void AddContextMenu(XmlWriter* xml_writer,
                    NotificationLaunchId copied_launch_id,
                    const std::string& settings_msg) {
  copied_launch_id.set_is_for_context_menu();
  WriteContextMenuElement(xml_writer, settings_msg,
                          copied_launch_id.Serialize());
}

// Ensures that every reminder has at least one button, as the Action Center
// does not respect the Reminder setting on notifications with no buttons, so we
// must add a Dismiss button to the notification for those cases. For more
// details, see issue https://crbug.com/781792.
void EnsureReminderHasButton(XmlWriter* xml_writer,
                             const message_center::Notification& notification,
                             NotificationLaunchId copied_launch_id) {
  if (!notification.never_timeout() || !notification.buttons().empty() ||
      base::FeatureList::IsEnabled(
          features::kNotificationDurationLongForRequireInteraction)) {
    return;
  }

  xml_writer->StartElement(kActionElement);
  xml_writer->AddAttribute(kActivationType, kBackground);
  xml_writer->AddAttribute(kContent, l10n_util::GetStringUTF8(IDS_APP_CLOSE));
  copied_launch_id.set_is_for_dismiss_button();
  xml_writer->AddAttribute(kArguments, copied_launch_id.Serialize());
  xml_writer->EndElement();
}

}  // namespace

const char kNotificationToastElement[] = "toast";
const char kNotificationLaunchAttribute[] = "launch";

// libXml was preferred (over WinXml, which the samples in the link below tend
// to use) for building the XML template because it is used frequently in
// Chrome, is nicer to use and has already been vetted.
// https://docs.microsoft.com/en-us/windows/uwp/controls-and-patterns/tiles-and-notifications-adaptive-interactive-toasts
std::wstring BuildNotificationTemplate(
    NotificationImageRetainer* image_retainer,
    const NotificationLaunchId& launch_id,
    const message_center::Notification& notification) {
  DCHECK(image_retainer);

  XmlWriter xml_writer;
  xml_writer.StartWriting();

  StartToastElement(&xml_writer, launch_id, notification);
  StartVisualElement(&xml_writer);

  StartBindingElement(&xml_writer, kDefaultTemplate);

  // Content for the toast template.
  WriteTextElement(&xml_writer, base::UTF16ToUTF8(notification.title()),
                   TextType::NORMAL);

  // Message has historically not been shown for list-style notifications.
  if (notification.type() == message_center::NOTIFICATION_TYPE_MULTIPLE &&
      !notification.items().empty()) {
    WriteItems(&xml_writer, notification.items());
  } else {
    WriteTextElement(&xml_writer, base::UTF16ToUTF8(notification.message()),
                     TextType::NORMAL);
  }

  std::string attribution;
  if (notification.UseOriginAsContextMessage())
    attribution = FormatOrigin(notification.origin_url());
  else if (!notification.context_message().empty())
    attribution = base::UTF16ToUTF8(notification.context_message());

  if (!attribution.empty())
    WriteTextElement(&xml_writer, attribution, TextType::ATTRIBUTION);

  if (!notification.icon().IsEmpty())
    WriteIconElement(&xml_writer, image_retainer, notification);

  if (!notification.image().IsEmpty())
    WriteLargeImageElement(&xml_writer, image_retainer, notification);

  if (notification.type() == message_center::NOTIFICATION_TYPE_PROGRESS)
    WriteProgressElement(&xml_writer, notification);

  EndBindingElement(&xml_writer);
  EndVisualElement(&xml_writer);

  StartActionsElement(&xml_writer);
  if (!notification.buttons().empty())
    AddActions(&xml_writer, image_retainer, notification, launch_id);
  EnsureReminderHasButton(&xml_writer, notification, launch_id);
  if (notification.should_show_settings_button()) {
    if (context_menu_label_override) {
      AddContextMenu(&xml_writer, launch_id, context_menu_label_override);
    } else {
      AddContextMenu(&xml_writer, launch_id,
                     l10n_util::GetStringUTF8(
                         IDS_WIN_NOTIFICATION_SETTINGS_CONTEXT_MENU_ITEM_NAME));
    }
  } else {
    DCHECK(!context_menu_label_override)
        << "Must show custom settings button label";
  }
  EndActionsElement(&xml_writer);

  if (notification.silent())
    WriteAudioSilentElement(&xml_writer);

  EndToastElement(&xml_writer);

  xml_writer.StopWriting();

  std::string template_xml = xml_writer.GetWrittenString();
  DCHECK(base::StartsWith(template_xml, kXmlVersionHeader,
                          base::CompareCase::SENSITIVE));

  // The |kXmlVersionHeader| is automatically appended by libxml, but the toast
  // system in the Windows Action Center expects it to be absent.
  return base::UTF8ToWide(
      std::string_view(template_xml).substr(sizeof(kXmlVersionHeader) - 1));
}

void SetContextMenuLabelForTesting(const char* label) {
  context_menu_label_override = label;
}