// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/40285824): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif
#include "ash/webui/focus_mode/focus_mode_ui.h"
#include <optional>
#include "ash/constants/ash_features.h"
#include "ash/constants/url_constants.h"
#include "ash/style/switch.h"
#include "ash/system/focus_mode/focus_mode_controller.h"
#include "ash/system/focus_mode/sounds/focus_mode_sounds_controller.h"
#include "ash/system/focus_mode/sounds/youtube_music/youtube_music_types.h"
#include "ash/webui/common/trusted_types_util.h"
#include "ash/webui/focus_mode/mojom/focus_mode.mojom-shared.h"
#include "ash/webui/grit/ash_focus_mode_resources.h"
#include "ash/webui/grit/ash_focus_mode_resources_map.h"
#include "base/base64.h"
#include "base/containers/flat_set.h"
#include "base/logging.h"
#include "base/memory/weak_ptr.h"
#include "base/strings/stringprintf.h"
#include "base/time/time.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_ui.h"
#include "content/public/browser/web_ui_data_source.h"
#include "content/public/common/bindings_policy.h"
#include "content/public/common/url_constants.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "third_party/blink/public/common/web_preferences/web_preferences.h"
#include "ui/gfx/codec/webp_codec.h"
#include "ui/gfx/image/image_skia_operations.h"
#include "ui/webui/webui_allowlist.h"
#include "url/url_constants.h"
namespace ash {
namespace {
// The artwork needs to be at least this big to be shown. If the source is
// smaller, we'll scale it up to this size. This constant is based on
// global_media_controls::kMediaItemArtworkMinSize.
constexpr gfx::Size kArtworkMinSize(114, 114);
// Minimum time interval of the rate limiter between two playback reports for
// the same track.
constexpr base::TimeDelta kRateLimitingInterval = base::Seconds(6);
// Resizes an image so that it is at least `kArtworkMinSize`.
gfx::ImageSkia EnsureMinSize(const gfx::ImageSkia& image) {
// We are assuming that the input artwork is roughly square in aspect ratio.
if (image.width() < kArtworkMinSize.width() ||
image.height() < kArtworkMinSize.height()) {
return gfx::ImageSkiaOperations::CreateResizedImage(
image, skia::ImageOperations::RESIZE_GOOD, kArtworkMinSize);
}
return image;
}
// Takes the given image, encodes it as webp and returns it in the form of a
// data URL. Returns an empty URL on error.
GURL MakeImageDataURL(const gfx::ImageSkia& image) {
if (image.isNull()) {
return {};
}
gfx::ImageSkia resized_image = EnsureMinSize(image);
std::vector<unsigned char> webp_data;
if (!gfx::WebpCodec::Encode(*resized_image.bitmap(), 50, &webp_data)) {
return {};
}
GURL url("data:image/webp;base64," + base::Base64Encode(webp_data));
if (url.spec().size() > url::kMaxURLChars) {
return {};
}
return url;
}
youtube_music::PlaybackState GetPlaybackState(
const focus_mode::mojom::PlaybackState playback_state) {
switch (playback_state) {
case focus_mode::mojom::PlaybackState::kPlaying:
return youtube_music::PlaybackState::kPlaying;
case focus_mode::mojom::PlaybackState::kPaused:
return youtube_music::PlaybackState::kPaused;
case focus_mode::mojom::PlaybackState::kSwitchedToNext:
return youtube_music::PlaybackState::kSwitchedToNext;
case focus_mode::mojom::PlaybackState::kEnded:
return youtube_music::PlaybackState::kEnded;
case focus_mode::mojom::PlaybackState::kNone:
return youtube_music::PlaybackState::kNone;
}
}
bool ValidatePlaybackData(const focus_mode::mojom::PlaybackDataPtr& data) {
if (data.is_null()) {
DLOG(ERROR) << "Failed to validate the playback data: empty data";
return false;
}
if (data->state == focus_mode::mojom::PlaybackState::kNone) {
DLOG(ERROR) << "Failed to validate the playback data: uninitialized state";
return false;
}
if (data->initial_playback) {
if (data->media_start.has_value() || data->media_end.has_value()) {
DLOG(ERROR)
<< "Failed to validate the playback data: bad initial playback data";
return false;
}
} else {
if (!data->media_start.has_value() || !data->media_end.has_value() ||
data->media_start < 0 || data->media_start > 18000 ||
data->media_end < 0 || data->media_end > 18000 ||
data->media_start >= data->media_end) {
DLOG(ERROR) << "Failed to validate the playback data: bad subsequent "
"playback data, media_start="
<< data->media_start.value_or(-1)
<< ", media_end=" << data->media_end.value_or(-1);
return false;
}
}
return true;
}
} // namespace
class FocusModeTrackProvider : public focus_mode::mojom::TrackProvider {
public:
void GetTrack(GetTrackCallback callback) override {
auto* sounds_controller =
FocusModeController::Get()->focus_mode_sounds_controller();
sounds_controller->GetNextTrack(
base::BindOnce(&FocusModeTrackProvider::HandleTrack,
weak_factory_.GetWeakPtr(), std::move(callback)));
}
void SetMediaClient(
mojo::PendingRemote<focus_mode::mojom::MediaClient> client) override {
client_remote_.reset();
client_remote_.Bind(std::move(client));
}
void ReportPlayback(focus_mode::mojom::PlaybackDataPtr data) override {
if (!ValidatePlaybackData(data)) {
return;
}
base::flat_set<std::pair<int, int>> media_segments;
if (data->media_start.has_value() && data->media_end.has_value()) {
media_segments.insert(
{data->media_start.value(), data->media_end.value()});
}
rate_limiter_.OnPlaybackEvent(
youtube_music::PlaybackData(GetPlaybackState(data->state), data->title,
data->url, media_segments,
data->initial_playback),
base::Time::Now());
}
void BindInterface(
mojo::PendingReceiver<focus_mode::mojom::TrackProvider> receiver) {
receiver_.reset();
receiver_.Bind(std::move(receiver));
}
private:
// A simple rate limiter for YouTube Music APIs.
class YTMRateLimiter {
public:
bool ShouldLimit(const youtube_music::PlaybackData& playback_data,
const base::Time timestamp) {
// Do not limit if it's a different track that can not be aggregated.
const bool same_track =
last_playback_.has_value() &&
last_playback_.value().CanAggregateWithNewData(playback_data) &&
(!pending_playback_.has_value() ||
pending_playback_.value().CanAggregateWithNewData(playback_data));
if (!same_track) {
return false;
}
// Do not limit if it's not within the interval.
const bool within_interval =
last_timestamp_.has_value() &&
timestamp < last_timestamp_.value() + kRateLimitingInterval;
if (!within_interval) {
return false;
}
// Do not limit if it's the last event for the track.
const bool last_event =
playback_data.state ==
youtube_music::PlaybackState::kSwitchedToNext ||
playback_data.state == youtube_music::PlaybackState::kEnded;
return !last_event;
}
void OnPlaybackEvent(youtube_music::PlaybackData playback_data,
const base::Time timestamp) {
FocusModeSoundsController* sounds_controller =
FocusModeController::Get()->focus_mode_sounds_controller();
if (!sounds_controller) {
return;
}
if (ShouldLimit(playback_data, timestamp)) {
// If it should limit, aggregate the new data into the pending data and
// wait for the next event.
if (pending_playback_.has_value()) {
pending_playback_->AggregateWithNewData(playback_data);
} else {
pending_playback_ = playback_data;
}
} else {
// If it should *not* limit, either:
// - If the pending data and the new data are from the *same* track,
// aggregate the pending data into the new data and report it.
// - If the pending data and the new data are from *different* tracks,
// report the pending data and the new data separately.
if (pending_playback_.has_value()) {
if (pending_playback_.value().CanAggregateWithNewData(
playback_data)) {
playback_data.AggregateWithNewData(pending_playback_.value());
} else {
sounds_controller->ReportYouTubeMusicPlayback(
pending_playback_.value());
}
pending_playback_.reset();
}
sounds_controller->ReportYouTubeMusicPlayback(playback_data);
last_timestamp_ = timestamp;
last_playback_ = playback_data;
}
}
private:
// Timestamp for last reported playback.
std::optional<base::Time> last_timestamp_ = std::nullopt;
// Last reported playback data.
std::optional<youtube_music::PlaybackData> last_playback_ = std::nullopt;
// Pending playback data to report to the backend.
std::optional<youtube_music::PlaybackData> pending_playback_ = std::nullopt;
};
void HandleTrack(focus_mode::mojom::TrackProvider::GetTrackCallback callback,
const std::optional<FocusModeSoundsDelegate::Track>& track) {
if (!track) {
std::move(callback).Run(focus_mode::mojom::TrackDefinition::New());
return;
}
// If there is no thumbnail, then we can reply immediately.
if (!track->thumbnail_url.is_valid()) {
auto mojo_track = focus_mode::mojom::TrackDefinition::New(
track->title, track->artist, /*thumbnail_url=*/GURL{},
track->source_url, track->enable_playback_reporting);
std::move(callback).Run(std::move(mojo_track));
return;
}
// Otherwise we need to download and convert the thumbnail first.
FocusModeSoundsController::DownloadTrackThumbnail(
track->thumbnail_url,
base::BindOnce(&FocusModeTrackProvider::OnThumbnailDownloaded,
weak_factory_.GetWeakPtr(), std::move(callback),
*track));
}
void OnThumbnailDownloaded(GetTrackCallback callback,
const FocusModeSoundsDelegate::Track& track,
const gfx::ImageSkia& image) {
auto mojo_track = focus_mode::mojom::TrackDefinition::New(
track.title, track.artist, MakeImageDataURL(image), track.source_url,
track.enable_playback_reporting);
std::move(callback).Run(std::move(mojo_track));
}
YTMRateLimiter rate_limiter_;
mojo::Remote<focus_mode::mojom::MediaClient> client_remote_;
mojo::Receiver<focus_mode::mojom::TrackProvider> receiver_{this};
base::WeakPtrFactory<FocusModeTrackProvider> weak_factory_{this};
};
FocusModeUI::FocusModeUI(content::WebUI* web_ui)
: ui::MojoWebUIController(web_ui),
track_provider_(std::make_unique<FocusModeTrackProvider>()) {
// Set up the chrome://focus-mode-media source. Note that for the trusted
// page, we need to pass the *host* as second parameter.
content::WebUIDataSource* source = content::WebUIDataSource::CreateAndAdd(
web_ui->GetWebContents()->GetBrowserContext(),
chrome::kChromeUIFocusModeMediaHost);
// This is needed so that the page can load the iframe from chrome-untrusted.
web_ui->AddRequestableScheme(content::kChromeUIUntrustedScheme);
// Setup chrome://focus-mode-media main page.
source->AddResourcePath("", IDR_ASH_FOCUS_MODE_FOCUS_MODE_HTML);
// Add chrome://focus-mode-media content.
source->AddResourcePaths(
base::make_span(kAshFocusModeResources, kAshFocusModeResourcesSize));
source->OverrideContentSecurityPolicy(
network::mojom::CSPDirectiveName::DefaultSrc, "default-src 'self';");
// Enables the page to load the untrusted page in an iframe.
source->OverrideContentSecurityPolicy(
network::mojom::CSPDirectiveName::FrameSrc,
base::StringPrintf("frame-src %s;", chrome::kChromeUIFocusModePlayerURL));
ash::EnableTrustedTypesCSP(source);
// This sets the untrusted page to be in a web app scope. This in turn enables
// autoplay of audio on the page. Without this, the page would require user
// interaction in order to play audio, which isn't possible since the web UI
// is hidden. See AutoPlayPolicy::GetAutoplayPolicyForDocument for more info.
auto* web_contents = web_ui->GetWebContents();
auto prefs = web_contents->GetOrCreateWebPreferences();
prefs.web_app_scope = GURL(chrome::kChromeUIFocusModePlayerURL);
web_contents->SetWebPreferences(prefs);
}
FocusModeUI::~FocusModeUI() = default;
void FocusModeUI::BindInterface(
mojo::PendingReceiver<focus_mode::mojom::TrackProvider> receiver) {
track_provider_->BindInterface(std::move(receiver));
}
WEB_UI_CONTROLLER_TYPE_IMPL(FocusModeUI)
FocusModeUIConfig::FocusModeUIConfig()
: DefaultWebUIConfig(content::kChromeUIScheme,
chrome::kChromeUIFocusModeMediaHost) {}
bool FocusModeUIConfig::IsWebUIEnabled(
content::BrowserContext* browser_context) {
return ash::features::IsFocusModeEnabled();
}
} // namespace ash