diff options
Diffstat (limited to 'chromium/components/browser_ui/media')
44 files changed, 2808 insertions, 0 deletions
diff --git a/chromium/components/browser_ui/media/OWNERS b/chromium/components/browser_ui/media/OWNERS new file mode 100644 index 00000000000..28f64e589e9 --- /dev/null +++ b/chromium/components/browser_ui/media/OWNERS @@ -0,0 +1,4 @@ +mlamouri@chromium.org + +# TEAM: media-dev@chromium.org +# COMPONENT: Internals>Media>UI diff --git a/chromium/components/browser_ui/media/android/BUILD.gn b/chromium/components/browser_ui/media/android/BUILD.gn new file mode 100644 index 00000000000..31b7a047305 --- /dev/null +++ b/chromium/components/browser_ui/media/android/BUILD.gn @@ -0,0 +1,95 @@ +# Copyright 2020 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. + +import("//build/config/android/rules.gni") + +android_library("java") { + sources = [ + "java/src/org/chromium/components/browser_ui/media/MediaImageCallback.java", + "java/src/org/chromium/components/browser_ui/media/MediaImageManager.java", + "java/src/org/chromium/components/browser_ui/media/MediaNotificationController.java", + "java/src/org/chromium/components/browser_ui/media/MediaNotificationImageUtils.java", + "java/src/org/chromium/components/browser_ui/media/MediaNotificationInfo.java", + "java/src/org/chromium/components/browser_ui/media/MediaNotificationListener.java", + "java/src/org/chromium/components/browser_ui/media/MediaNotificationUma.java", + "java/src/org/chromium/components/browser_ui/media/MediaSessionHelper.java", + "java/src/org/chromium/components/browser_ui/media/MediaSessionUma.java", + ] + + deps = [ + ":java_resources", + "//base:base_java", + "//components/browser_ui/notifications/android:java", + "//components/url_formatter/android:url_formatter_java", + "//content/public/android:content_java", + "//services/media_session/public/cpp/android:media_session_java", + "//services/media_session/public/mojom:mojom_java", + "//third_party/android_deps:android_support_v4_java", + "//ui/android:ui_full_java", + "//url:gurl_java", + ] +} + +android_resources("java_resources") { + custom_package = "org.chromium.components.browser_ui.media" + sources = [ + "java/res/drawable-hdpi/audio_playing.png", + "java/res/drawable-hdpi/audio_playing_square.png", + "java/res/drawable-hdpi/ic_fast_forward_white_36dp.png", + "java/res/drawable-hdpi/ic_fast_rewind_white_36dp.png", + "java/res/drawable-hdpi/ic_skip_next_white_36dp.png", + "java/res/drawable-hdpi/ic_skip_previous_white_36dp.png", + "java/res/drawable-mdpi/audio_playing.png", + "java/res/drawable-mdpi/audio_playing_square.png", + "java/res/drawable-mdpi/ic_fast_forward_white_36dp.png", + "java/res/drawable-mdpi/ic_fast_rewind_white_36dp.png", + "java/res/drawable-mdpi/ic_skip_next_white_36dp.png", + "java/res/drawable-mdpi/ic_skip_previous_white_36dp.png", + "java/res/drawable-xhdpi/audio_playing.png", + "java/res/drawable-xhdpi/audio_playing_square.png", + "java/res/drawable-xhdpi/ic_fast_forward_white_36dp.png", + "java/res/drawable-xhdpi/ic_fast_rewind_white_36dp.png", + "java/res/drawable-xhdpi/ic_skip_next_white_36dp.png", + "java/res/drawable-xhdpi/ic_skip_previous_white_36dp.png", + "java/res/drawable-xxhdpi/audio_playing.png", + "java/res/drawable-xxhdpi/audio_playing_square.png", + "java/res/drawable-xxhdpi/ic_fast_forward_white_36dp.png", + "java/res/drawable-xxhdpi/ic_fast_rewind_white_36dp.png", + "java/res/drawable-xxhdpi/ic_skip_next_white_36dp.png", + "java/res/drawable-xxhdpi/ic_skip_previous_white_36dp.png", + "java/res/drawable-xxxhdpi/audio_playing.png", + "java/res/drawable-xxxhdpi/audio_playing_square.png", + "java/res/drawable-xxxhdpi/ic_fast_forward_white_36dp.png", + "java/res/drawable-xxxhdpi/ic_fast_rewind_white_36dp.png", + "java/res/drawable-xxxhdpi/ic_skip_next_white_36dp.png", + "java/res/drawable-xxxhdpi/ic_skip_previous_white_36dp.png", + ] + deps = [ + "//components/browser_ui/strings/android:browser_ui_strings_grd", + "//components/browser_ui/styles/android:java_resources", + ] +} + +java_library("junit") { + # Skip platform checks since Robolectric depends on requires_android targets. + bypass_platform_checks = true + testonly = true + sources = [ + "java/src/org/chromium/components/browser_ui/media/MediaImageManagerTest.java", + "java/src/org/chromium/components/browser_ui/media/MediaNotificationButtonComputationTest.java", + ] + deps = [ + ":java", + "//base:base_java", + "//base:base_java_test_support", + "//base:base_junit_test_support", + "//base/test:test_support_java", + "//content/public/android:content_java", + "//services/media_session/public/cpp/android:media_session_java", + "//services/media_session/public/mojom:mojom_java", + "//third_party/android_deps:robolectric_all_java", + "//third_party/junit", + "//third_party/mockito:mockito_java", + ] +} diff --git a/chromium/components/browser_ui/media/android/DEPS b/chromium/components/browser_ui/media/android/DEPS new file mode 100644 index 00000000000..652050725b0 --- /dev/null +++ b/chromium/components/browser_ui/media/android/DEPS @@ -0,0 +1,6 @@ +include_rules = [ + "+components/url_formatter/android", + "+content/public/android", + "+services/media_session/public/cpp/android", + "+ui/android", +] diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing.png b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing.png Binary files differnew file mode 100644 index 00000000000..75d13258a70 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing_square.png b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing_square.png Binary files differnew file mode 100644 index 00000000000..94108427da0 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing_square.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_forward_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_forward_white_36dp.png Binary files differnew file mode 100644 index 00000000000..6a7db4b8c80 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_forward_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_rewind_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_rewind_white_36dp.png Binary files differnew file mode 100644 index 00000000000..656d0220963 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_rewind_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_next_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_next_white_36dp.png Binary files differnew file mode 100644 index 00000000000..cf68df833a9 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_next_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_previous_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_previous_white_36dp.png Binary files differnew file mode 100644 index 00000000000..da1c1c958f2 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_previous_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing.png b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing.png Binary files differnew file mode 100644 index 00000000000..a9ccbc15101 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing_square.png b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing_square.png Binary files differnew file mode 100644 index 00000000000..acbe6dbea23 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing_square.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_forward_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_forward_white_36dp.png Binary files differnew file mode 100644 index 00000000000..f890f113715 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_forward_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_rewind_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_rewind_white_36dp.png Binary files differnew file mode 100644 index 00000000000..9d02d436605 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_rewind_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_next_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_next_white_36dp.png Binary files differnew file mode 100644 index 00000000000..9032328d4df --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_next_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_previous_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_previous_white_36dp.png Binary files differnew file mode 100644 index 00000000000..23faeeb0264 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_previous_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing.png b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing.png Binary files differnew file mode 100644 index 00000000000..7fcb219818e --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing_square.png b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing_square.png Binary files differnew file mode 100644 index 00000000000..212aa900ffb --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing_square.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_forward_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_forward_white_36dp.png Binary files differnew file mode 100644 index 00000000000..f7d810f1248 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_forward_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_rewind_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_rewind_white_36dp.png Binary files differnew file mode 100644 index 00000000000..12ff39ab480 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_rewind_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_next_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_next_white_36dp.png Binary files differnew file mode 100644 index 00000000000..972192d3937 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_next_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_previous_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_previous_white_36dp.png Binary files differnew file mode 100644 index 00000000000..1181ec926a8 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_previous_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing.png Binary files differnew file mode 100644 index 00000000000..a743b72ac6b --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing_square.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing_square.png Binary files differnew file mode 100644 index 00000000000..403f6945051 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing_square.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_forward_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_forward_white_36dp.png Binary files differnew file mode 100644 index 00000000000..b41b3de40b6 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_forward_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_rewind_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_rewind_white_36dp.png Binary files differnew file mode 100644 index 00000000000..253833bbc36 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_rewind_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_next_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_next_white_36dp.png Binary files differnew file mode 100644 index 00000000000..4652215ccc8 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_next_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_previous_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_previous_white_36dp.png Binary files differnew file mode 100644 index 00000000000..c8db47f6635 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_previous_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing.png Binary files differnew file mode 100644 index 00000000000..5139cbdba20 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing_square.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing_square.png Binary files differnew file mode 100644 index 00000000000..e994ee339dd --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing_square.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_forward_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_forward_white_36dp.png Binary files differnew file mode 100644 index 00000000000..b111f7d2541 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_forward_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_rewind_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_rewind_white_36dp.png Binary files differnew file mode 100644 index 00000000000..e1baaa33127 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_rewind_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_next_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_next_white_36dp.png Binary files differnew file mode 100644 index 00000000000..00b29dd1703 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_next_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_previous_white_36dp.png b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_previous_white_36dp.png Binary files differnew file mode 100644 index 00000000000..9e52d5001ea --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_previous_white_36dp.png diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageCallback.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageCallback.java new file mode 100644 index 00000000000..117373ad0ef --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageCallback.java @@ -0,0 +1,22 @@ +// Copyright 2016 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.components.browser_ui.media; + +import android.graphics.Bitmap; + +import androidx.annotation.Nullable; + +/** + * The callback when an image is downloaded. This class is different with + * {@link ImageDownloadCallback} and is only used by {@link MediaImageManager}. + */ +public interface MediaImageCallback { + /** + * Called when image downloading is complete. + * @param bitmap The downloaded image. |null| indicates there is no available src for download + * or image download failed. + */ + void onImageDownloaded(@Nullable Bitmap image); +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManager.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManager.java new file mode 100644 index 00000000000..b2fa480fe90 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManager.java @@ -0,0 +1,235 @@ +// Copyright 2016 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.components.browser_ui.media; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.text.TextUtils; + +import androidx.annotation.VisibleForTesting; + +import org.chromium.base.FileUtils; +import org.chromium.content_public.browser.ImageDownloadCallback; +import org.chromium.content_public.browser.WebContents; +import org.chromium.services.media_session.MediaImage; + +import java.util.Iterator; +import java.util.List; + +/** + * A class for managing the MediaImage download process. + * + * The manager takes a list of {@link MediaMetadata.MediaImage} as input, and + * selects one of them based on scoring and start download through + * {@link WebContents} asynchronously. When the download successfully finishes, + * the manager runs the callback function to notify the completion and pass the + * downloaded Bitmap. + * + * The scoring works as follows: + * - A image score is computed by multiplying the type score with the size score. + * - The type score lies in [0, 1] and is based on the image MIME type/file extension. + * - PNG and JPEG are prefered than others. + * - If unspecified, use the default type score (0.6). + * - The size score lies in [0, 1] and is computed by multiplying the dominant size score and aspect + * ratio score: + * - The dominant size score lies in [0, 1] and is computed using |mMinimumSize| and |mIdealSize|: + * - If size < |mMinimumSize| (too small), the size score is 0. + * - If |mMinimumSize| <= size <= |mIdealSize|, the score increases linearly from 0.2 to 1. + * - If size > |mIdealSize|, the score is |mIdealSize| / size, which drops from 1 to 0. + * - When the size is "any", the size score is 0.8. + * - If unspecified, use the default size score (0.4). + * - The aspect ratio score lies in [0, 1] and is computed by dividing the short edge length by + * the long edge. + */ +public class MediaImageManager implements ImageDownloadCallback { + // The default score of unknown image size. + private static final double DEFAULT_IMAGE_SIZE_SCORE = 0.4; + // The scores for different image types. Keep them sorted by value. + private static final double TYPE_SCORE_DEFAULT = 0.6; + private static final double TYPE_SCORE_PNG = 1.0; + private static final double TYPE_SCORE_JPEG = 0.7; + private static final double TYPE_SCORE_BMP = 0.5; + private static final double TYPE_SCORE_XICON = 0.4; + private static final double TYPE_SCORE_GIF = 0.3; + + @VisibleForTesting + static final int MAX_BITMAP_SIZE_FOR_DOWNLOAD = 2048; + + private WebContents mWebContents; + // The minimum image size. Images that are smaller than |mMinimumSize| will be ignored. + final int mMinimumSize; + // The ideal image size. Images that are too large than |mIdealSize| will be ignored. + final int mIdealSize; + // The pending download image request id, which is set when calling + // {@link WebContents#downloadImage()}, and reset when image download completes or + // {@link #clearRequests()} is called. + private int mRequestId; + // The callback to be called when the pending download image request completes. + private MediaImageCallback mCallback; + + // The last image src for download, used for avoiding fetching the same src when artwork is set + // multiple times but the same src is chosen. + // + // Will be reset when initiating a new download request. + private String mLastImageSrc; + + /** + * MediaImageManager constructor. + * @param minimumSize The minimum size of images to download. + * @param idealSize The ideal size of images to download. + */ + public MediaImageManager(int minimumSize, int idealSize) { + mMinimumSize = minimumSize; + mIdealSize = idealSize; + clearRequests(); + } + + /** + * Called when the WebContent changes. + * @param contents The new WebContents. + */ + public void setWebContents(WebContents contents) { + mWebContents = contents; + clearRequests(); + } + + /** + * Select the best image from |images| and start download. + * @param images The list of images to choose from. Null is equivalent to empty list. + * @param callback The callback when image download completes. + */ + public void downloadImage(List<MediaImage> images, MediaImageCallback callback) { + if (mWebContents == null) return; + + mCallback = callback; + MediaImage image = selectImage(images); + if (image == null) { + mLastImageSrc = null; + mCallback.onImageDownloaded(null); + clearRequests(); + return; + } + + // Avoid fetching the same image twice. + if (TextUtils.equals(image.getSrc(), mLastImageSrc)) return; + mLastImageSrc = image.getSrc(); + + // Limit |maxBitmapSize| to |MAX_BITMAP_SIZE_FOR_DOWNLOAD| to avoid passing huge bitmaps + // through JNI. |maxBitmapSize| does not prevent huge images to be downloaded. It is used to + // filter/rescale the download images. See documentation of + // {@link WebContents#downloadImage()} for details. + mRequestId = mWebContents.downloadImage(image.getSrc(), // url + false, // isFavicon + MAX_BITMAP_SIZE_FOR_DOWNLOAD, // maxBitmapSize + false, // bypassCache + this); // callback + } + + /** + * ImageDownloadCallback implementation. This method is called when an download image request is + * completed. The class will only keep the latest request. If some call to this method is + * corresponding to a previous request, it will be ignored. + */ + @Override + public void onFinishDownloadImage(int id, int httpStatusCode, String imageUrl, + List<Bitmap> bitmaps, List<Rect> originalImageSizes) { + if (id != mRequestId) return; + + Iterator<Bitmap> iterBitmap = bitmaps.iterator(); + Iterator<Rect> iterSize = originalImageSizes.iterator(); + + Bitmap bestBitmap = null; + double bestScore = 0; + while (iterBitmap.hasNext() && iterSize.hasNext()) { + Bitmap bitmap = iterBitmap.next(); + Rect size = iterSize.next(); + double newScore = getImageSizeScore(size); + if (bestScore < newScore) { + bestBitmap = bitmap; + bestScore = newScore; + } + } + mCallback.onImageDownloaded(bestBitmap); + clearRequests(); + } + + /** + * Select the best image from the |images|. + * @param images The list of images to select from. Null is equivalent to empty list. + */ + private MediaImage selectImage(List<MediaImage> images) { + if (images == null) return null; + + MediaImage selectedImage = null; + double bestScore = 0; + for (MediaImage image : images) { + double newScore = getImageScore(image); + if (newScore > bestScore) { + bestScore = newScore; + selectedImage = image; + } + } + return selectedImage; + } + + private void clearRequests() { + mRequestId = -1; + mCallback = null; + } + + private double getImageScore(MediaImage image) { + if (image == null) return 0; + if (image.getSizes().isEmpty()) return DEFAULT_IMAGE_SIZE_SCORE; + + double bestSizeScore = 0; + for (Rect size : image.getSizes()) { + bestSizeScore = Math.max(bestSizeScore, getImageSizeScore(size)); + } + double typeScore = getImageTypeScore(image.getSrc(), image.getType()); + return bestSizeScore * typeScore; + } + + private double getImageSizeScore(Rect size) { + return getImageDominantSizeScore(size.width(), size.height()) + * getImageAspectRatioScore(size.width(), size.height()); + } + + private double getImageDominantSizeScore(int width, int height) { + int dominantSize = Math.max(width, height); + // When the size is "any". + if (dominantSize == 0) return 0.8; + // Ignore images that are too small. + if (dominantSize < mMinimumSize) return 0; + + if (dominantSize <= mIdealSize) { + return 0.8 * (dominantSize - mMinimumSize) / (mIdealSize - mMinimumSize) + 0.2; + } + return 1.0 * mIdealSize / dominantSize; + } + + private double getImageAspectRatioScore(int width, int height) { + double longEdge = Math.max(width, height); + double shortEdge = Math.min(width, height); + return shortEdge / longEdge; + } + + private double getImageTypeScore(String url, String type) { + String extension = FileUtils.getExtension(url); + + if ("bmp".equals(extension) || "image/bmp".equals(type)) { + return TYPE_SCORE_BMP; + } else if ("gif".equals(extension) || "image/gif".equals(type)) { + return TYPE_SCORE_GIF; + } else if ("icon".equals(extension) || "image/x-icon".equals(type)) { + return TYPE_SCORE_XICON; + } else if ("png".equals(extension) || "image/png".equals(type)) { + return TYPE_SCORE_PNG; + } else if ("jpeg".equals(extension) || "jpg".equals(extension) + || "image/jpeg".equals(type)) { + return TYPE_SCORE_JPEG; + } + return TYPE_SCORE_DEFAULT; + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManagerTest.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManagerTest.java new file mode 100644 index 00000000000..c45340a77e9 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManagerTest.java @@ -0,0 +1,298 @@ +// Copyright 2017 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.components.browser_ui.media; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNotNull; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.graphics.Bitmap; +import android.graphics.Rect; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowLog; + +import org.chromium.base.test.BaseRobolectricTestRunner; +import org.chromium.content_public.browser.WebContents; +import org.chromium.services.media_session.MediaImage; + +import java.util.ArrayList; + +/** + * Robolectric tests for MediaImageManager. + */ +@RunWith(BaseRobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class MediaImageManagerTest { + private static final int TINY_IMAGE_SIZE_PX = 50; + private static final int MIN_IMAGE_SIZE_PX = 100; + private static final int IDEAL_IMAGE_SIZE_PX = 200; + private static final int REQUEST_ID_1 = 1; + private static final int REQUEST_ID_2 = 2; + private static final String IMAGE_URL_1 = "http://example.com/foo.png"; + private static final String IMAGE_URL_2 = "http://example.com/bar.png"; + + @Mock + private WebContents mWebContents; + @Mock + private MediaImageCallback mCallback; + + private MediaImageManager mMediaImageManager; + + // Prepared data for feeding. + private ArrayList<MediaImage> mImages; + private ArrayList<Bitmap> mBitmaps; + private ArrayList<Rect> mOriginalImageSizes; + + @Before + public void setUp() { + ShadowLog.stream = System.out; + MockitoAnnotations.initMocks(this); + doReturn(REQUEST_ID_1) + .when(mWebContents) + .downloadImage(anyString(), anyBoolean(), anyInt(), anyBoolean(), + any(MediaImageManager.class)); + mMediaImageManager = new MediaImageManager(MIN_IMAGE_SIZE_PX, IDEAL_IMAGE_SIZE_PX); + mMediaImageManager.setWebContents(mWebContents); + + mImages = new ArrayList<MediaImage>(); + mImages.add(new MediaImage(IMAGE_URL_1, "", new ArrayList<Rect>())); + + mBitmaps = new ArrayList<Bitmap>(); + mBitmaps.add(Bitmap.createBitmap( + IDEAL_IMAGE_SIZE_PX, IDEAL_IMAGE_SIZE_PX, Bitmap.Config.ARGB_8888)); + + mOriginalImageSizes = new ArrayList<Rect>(); + mOriginalImageSizes.add(new Rect(0, 0, IDEAL_IMAGE_SIZE_PX, IDEAL_IMAGE_SIZE_PX)); + } + + @Test + public void testDownloadImage() { + mMediaImageManager.downloadImage(mImages, mCallback); + verify(mWebContents) + .downloadImage(eq(IMAGE_URL_1), eq(false), + eq(MediaImageManager.MAX_BITMAP_SIZE_FOR_DOWNLOAD), eq(false), + eq(mMediaImageManager)); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + verify(mCallback).onImageDownloaded((Bitmap) isNotNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNull()); + } + + @Test + public void testDownloadSameImageTwice() { + // First download. + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + // Second download. + doReturn(REQUEST_ID_2) + .when(mWebContents) + .downloadImage(anyString(), anyBoolean(), anyInt(), anyBoolean(), + any(MediaImageManager.class)); + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_2, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + verify(mWebContents, times(1)) + .downloadImage(eq(IMAGE_URL_1), eq(false), + eq(MediaImageManager.MAX_BITMAP_SIZE_FOR_DOWNLOAD), eq(false), + eq(mMediaImageManager)); + verify(mCallback, times(1)).onImageDownloaded((Bitmap) isNotNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNull()); + } + + @Test + public void testDownloadSameImageTwiceButFailed() { + // First download. + mBitmaps.clear(); + mOriginalImageSizes.clear(); + + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 404, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + // Second download. + mMediaImageManager.downloadImage(mImages, mCallback); + // The second download request will never be initiated and the callback + // will be ignored. + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + verify(mWebContents, times(1)) + .downloadImage(eq(IMAGE_URL_1), eq(false), + eq(MediaImageManager.MAX_BITMAP_SIZE_FOR_DOWNLOAD), eq(false), + eq(mMediaImageManager)); + verify(mCallback, times(1)).onImageDownloaded((Bitmap) isNull()); + } + + @Test + public void testDownloadDifferentImagesTwice() { + // First download. + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + // Second download. + doReturn(REQUEST_ID_2) + .when(mWebContents) + .downloadImage(anyString(), anyBoolean(), anyInt(), anyBoolean(), + any(MediaImageManager.class)); + mImages.clear(); + mImages.add(new MediaImage(IMAGE_URL_2, "", new ArrayList<Rect>())); + + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_2, 200, IMAGE_URL_2, mBitmaps, mOriginalImageSizes); + + verify(mWebContents, times(1)) + .downloadImage(eq(IMAGE_URL_1), eq(false), + eq(MediaImageManager.MAX_BITMAP_SIZE_FOR_DOWNLOAD), eq(false), + eq(mMediaImageManager)); + verify(mWebContents, times(1)) + .downloadImage(eq(IMAGE_URL_2), eq(false), + eq(MediaImageManager.MAX_BITMAP_SIZE_FOR_DOWNLOAD), eq(false), + eq(mMediaImageManager)); + verify(mCallback, times(2)).onImageDownloaded((Bitmap) isNotNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNull()); + } + + @Test + public void testDownloadAnotherImageBeforeResponse() { + // First download. + mMediaImageManager.downloadImage(mImages, mCallback); + + // Second download. + doReturn(REQUEST_ID_2) + .when(mWebContents) + .downloadImage(anyString(), anyBoolean(), anyInt(), anyBoolean(), + any(MediaImageManager.class)); + mImages.clear(); + mImages.add(new MediaImage(IMAGE_URL_2, "", new ArrayList<Rect>())); + + mMediaImageManager.downloadImage(mImages, mCallback); + + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_2, 200, IMAGE_URL_2, mBitmaps, mOriginalImageSizes); + + // This reply should not be sent to the client. + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + verify(mWebContents, times(1)) + .downloadImage(eq(IMAGE_URL_1), eq(false), + eq(MediaImageManager.MAX_BITMAP_SIZE_FOR_DOWNLOAD), eq(false), + eq(mMediaImageManager)); + verify(mWebContents, times(1)) + .downloadImage(eq(IMAGE_URL_2), eq(false), + eq(MediaImageManager.MAX_BITMAP_SIZE_FOR_DOWNLOAD), eq(false), + eq(mMediaImageManager)); + + verify(mCallback, times(1)).onImageDownloaded((Bitmap) isNotNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNull()); + } + + @Test + public void testDuplicateResponce() { + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + verify(mCallback, times(1)).onImageDownloaded((Bitmap) isNotNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNull()); + } + + @Test + public void testWrongResponceId() { + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_2, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNotNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNull()); + } + + @Test + public void testTinyImagesRemovedBeforeDownloading() { + mImages.clear(); + ArrayList<Rect> sizes = new ArrayList<Rect>(); + sizes.add(new Rect(0, 0, TINY_IMAGE_SIZE_PX, TINY_IMAGE_SIZE_PX)); + mImages.add(new MediaImage(IMAGE_URL_1, "", sizes)); + mMediaImageManager.downloadImage(mImages, mCallback); + + verify(mWebContents, times(0)) + .downloadImage(anyString(), anyBoolean(), anyInt(), anyBoolean(), + any(MediaImageManager.class)); + verify(mCallback).onImageDownloaded((Bitmap) isNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNotNull()); + } + + @Test + public void testTinyImagesRemovedAfterDownloading() { + mMediaImageManager.downloadImage(mImages, mCallback); + + // Reset the data for feeding. + mBitmaps.clear(); + mBitmaps.add(Bitmap.createBitmap( + TINY_IMAGE_SIZE_PX, TINY_IMAGE_SIZE_PX, Bitmap.Config.ARGB_8888)); + mOriginalImageSizes.clear(); + mOriginalImageSizes.add(new Rect(0, 0, TINY_IMAGE_SIZE_PX, TINY_IMAGE_SIZE_PX)); + + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 200, IMAGE_URL_1, mBitmaps, mOriginalImageSizes); + + verify(mCallback).onImageDownloaded((Bitmap) isNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNotNull()); + } + + @Test + public void testDownloadImageFails() { + mMediaImageManager.downloadImage(mImages, mCallback); + mMediaImageManager.onFinishDownloadImage( + REQUEST_ID_1, 404, IMAGE_URL_1, new ArrayList<Bitmap>(), new ArrayList<Rect>()); + + verify(mCallback).onImageDownloaded((Bitmap) isNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNotNull()); + } + + @Test + public void testEmptyImageList() { + mImages.clear(); + mMediaImageManager.downloadImage(mImages, mCallback); + + verify(mWebContents, times(0)) + .downloadImage(anyString(), anyBoolean(), anyInt(), anyBoolean(), + any(MediaImageManager.class)); + verify(mCallback).onImageDownloaded((Bitmap) isNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNotNull()); + } + + @Test + public void testNullImageList() { + mMediaImageManager.downloadImage(null, mCallback); + + verify(mWebContents, times(0)) + .downloadImage(anyString(), anyBoolean(), anyInt(), anyBoolean(), + any(MediaImageManager.class)); + verify(mCallback).onImageDownloaded((Bitmap) isNull()); + verify(mCallback, times(0)).onImageDownloaded((Bitmap) isNotNull()); + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationButtonComputationTest.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationButtonComputationTest.java new file mode 100644 index 00000000000..94432be5b16 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationButtonComputationTest.java @@ -0,0 +1,96 @@ +// Copyright 2016 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.components.browser_ui.media; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +import org.chromium.base.test.BaseRobolectricTestRunner; +import org.chromium.base.test.util.Feature; +import org.chromium.media_session.mojom.MediaSessionAction; + +import java.util.ArrayList; + +/** + * Robolectric tests for compact view button computation in {@link MediaNotificationController}. + */ +@RunWith(BaseRobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class MediaNotificationButtonComputationTest { + @Test + @Feature({"MediaNotification"}) + public void testLessThanThreeActionsWillBeAllShownInCompactView() { + ArrayList<Integer> actions = new ArrayList<>(); + actions.add(MediaSessionAction.NEXT_TRACK); + actions.add(MediaSessionAction.SEEK_FORWARD); + actions.add(MediaSessionAction.PLAY); + + int[] compactViewActions = + MediaNotificationController.computeCompactViewActionIndices(actions); + + assertEquals(3, compactViewActions.length); + assertEquals(0, compactViewActions[0]); + assertEquals(1, compactViewActions[1]); + assertEquals(2, compactViewActions[2]); + } + + @Test + @Feature({"MediaNotification"}) + public void testCompactViewPrefersActionPairs_SwitchTrack() { + ArrayList<Integer> actions = new ArrayList<>(); + actions.add(MediaSessionAction.PREVIOUS_TRACK); + actions.add(MediaSessionAction.NEXT_TRACK); + actions.add(MediaSessionAction.SEEK_FORWARD); + actions.add(MediaSessionAction.PLAY); + + int[] compactViewActions = + MediaNotificationController.computeCompactViewActionIndices(actions); + + assertEquals(3, compactViewActions.length); + assertEquals(0, compactViewActions[0]); + assertEquals(3, compactViewActions[1]); + assertEquals(1, compactViewActions[2]); + } + + @Test + @Feature({"MediaNotification"}) + public void testCompactViewPrefersActionPairs_Seek() { + ArrayList<Integer> actions = new ArrayList<>(); + actions.add(MediaSessionAction.NEXT_TRACK); + actions.add(MediaSessionAction.SEEK_BACKWARD); + actions.add(MediaSessionAction.SEEK_FORWARD); + actions.add(MediaSessionAction.PLAY); + + int[] compactViewActions = + MediaNotificationController.computeCompactViewActionIndices(actions); + + assertEquals(3, compactViewActions.length); + assertEquals(1, compactViewActions[0]); + assertEquals(3, compactViewActions[1]); + assertEquals(2, compactViewActions[2]); + } + + @Test + @Feature({"MediaNotification"}) + public void testCompactViewPreferSwitchTrackWhenBothPairsExist() { + ArrayList<Integer> actions = new ArrayList<>(); + actions.add(MediaSessionAction.PREVIOUS_TRACK); + actions.add(MediaSessionAction.NEXT_TRACK); + actions.add(MediaSessionAction.SEEK_BACKWARD); + actions.add(MediaSessionAction.SEEK_FORWARD); + actions.add(MediaSessionAction.PLAY); + + int[] compactViewActions = + MediaNotificationController.computeCompactViewActionIndices(actions); + + assertEquals(3, compactViewActions.length); + assertEquals(0, compactViewActions[0]); + assertEquals(4, compactViewActions[1]); + assertEquals(1, compactViewActions[2]); + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationController.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationController.java new file mode 100644 index 00000000000..819ddde1e61 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationController.java @@ -0,0 +1,905 @@ +// Copyright 2020 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.components.browser_ui.media; + +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioManager; +import android.os.Build; +import android.os.Handler; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.text.TextUtils; +import android.util.SparseArray; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.chromium.base.CollectionUtil; +import org.chromium.base.ContextUtils; +import org.chromium.base.Log; +import org.chromium.components.browser_ui.notifications.ChromeNotification; +import org.chromium.components.browser_ui.notifications.ChromeNotificationBuilder; +import org.chromium.components.browser_ui.notifications.ForegroundServiceUtils; +import org.chromium.components.browser_ui.notifications.NotificationManagerProxy; +import org.chromium.components.browser_ui.notifications.NotificationManagerProxyImpl; +import org.chromium.media_session.mojom.MediaSessionAction; +import org.chromium.services.media_session.MediaMetadata; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A class that manages the notification, foreground service, and {@link MediaSessionCompat} for a + * specific type of media. + */ +public class MediaNotificationController { + private static final String TAG = "MediaNotification"; + + // The maximum number of actions in CompactView media notification. + private static final int COMPACT_VIEW_ACTIONS_COUNT = 3; + + // The maximum number of actions in BigView media notification. + private static final int BIG_VIEW_ACTIONS_COUNT = 5; + + // These string values reflect legacy class hierarchy. + public static final String ACTION_PLAY = "MediaNotificationManager.ListenerService.PLAY"; + public static final String ACTION_PAUSE = "MediaNotificationManager.ListenerService.PAUSE"; + public static final String ACTION_STOP = "MediaNotificationManager.ListenerService.STOP"; + public static final String ACTION_SWIPE = "MediaNotificationManager.ListenerService.SWIPE"; + public static final String ACTION_CANCEL = "MediaNotificationManager.ListenerService.CANCEL"; + public static final String ACTION_PREVIOUS_TRACK = + "MediaNotificationManager.ListenerService.PREVIOUS_TRACK"; + public static final String ACTION_NEXT_TRACK = + "MediaNotificationManager.ListenerService.NEXT_TRACK"; + public static final String ACTION_SEEK_FORWARD = + "MediaNotificationManager.ListenerService.SEEK_FORWARD"; + public static final String ACTION_SEEK_BACKWARD = + "MediaNotificationmanager.ListenerService.SEEK_BACKWARD"; + + // Overrides N detection. The production code will use |null|, which uses the Android version + // code. Otherwise, |isRunningAtLeastN()| will return whatever value is set. + @VisibleForTesting + public static Boolean sOverrideIsRunningNForTesting; + + // ListenerService running for the notification. Only non-null when showing. + @VisibleForTesting + public Service mService; + + @VisibleForTesting + public Delegate mDelegate; + + private SparseArray<MediaButtonInfo> mActionToButtonInfo; + + @VisibleForTesting + public ChromeNotificationBuilder mNotificationBuilder; + + @VisibleForTesting + public Bitmap mDefaultNotificationLargeIcon; + + // |mMediaNotificationInfo| should be not null if and only if the notification is showing. + @VisibleForTesting + public MediaNotificationInfo mMediaNotificationInfo; + + @VisibleForTesting + public MediaSessionCompat mMediaSession; + + @VisibleForTesting + public Throttler mThrottler; + + @VisibleForTesting + public static class Throttler { + @VisibleForTesting + public static final int THROTTLE_MILLIS = 500; + + @VisibleForTesting + public MediaNotificationController mController; + + private final Handler mHandler; + + @VisibleForTesting + public Throttler(@NonNull MediaNotificationController manager) { + mController = manager; + mHandler = new Handler(); + } + + // When |mTask| is non-null, it will always be queued in mHandler. When |mTask| is non-null, + // all notification updates will be throttled and their info will be stored as + // mLastPendingInfo. When |mTask| fires, it will call {@link showNotification()} with + // the latest queued notification info. + @VisibleForTesting + public Runnable mTask; + + // The last pending info. If non-null, it will be the latest notification info. + // Otherwise, the latest notification info will be |mController.mMediaNotificationInfo|. + @VisibleForTesting + public MediaNotificationInfo mLastPendingInfo; + + /** + * Queue |mediaNotificationInfo| for update. In unthrottled state (i.e. |mTask| != null), + * the notification will be updated immediately and enter the throttled state. In + * unthrottled state, the method will only update the pending notification info, which will + * be used for updating the notification when |mTask| is fired. + * + * @param mediaNotificationInfo The notification info to be queued. + */ + public void queueNotification(MediaNotificationInfo mediaNotificationInfo) { + assert mediaNotificationInfo != null; + + MediaNotificationInfo latestMediaNotificationInfo = mLastPendingInfo != null + ? mLastPendingInfo + : mController.mMediaNotificationInfo; + + if (shouldIgnoreMediaNotificationInfo( + latestMediaNotificationInfo, mediaNotificationInfo)) { + return; + } + + if (mTask == null) { + showNotificationImmediately(mediaNotificationInfo); + } else { + mLastPendingInfo = mediaNotificationInfo; + } + } + + /** + * Clears the pending notification and enter unthrottled state. + */ + public void clearPendingNotifications() { + mHandler.removeCallbacks(mTask); + mLastPendingInfo = null; + mTask = null; + } + + @VisibleForTesting + public void showNotificationImmediately(MediaNotificationInfo mediaNotificationInfo) { + // If no notification hasn't been updated in the last THROTTLE_MILLIS, update + // immediately and queue a task for blocking further updates. + mController.showNotification(mediaNotificationInfo); + mTask = new Runnable() { + @Override + public void run() { + if (mLastPendingInfo != null) { + // If any notification info is pended during the throttling time window, + // update the notification. + showNotificationImmediately(mLastPendingInfo); + mLastPendingInfo = null; + } else { + // Otherwise, clear the task so further update is unthrottled. + mTask = null; + } + } + }; + if (!mHandler.postDelayed(mTask, THROTTLE_MILLIS)) { + Log.w(TAG, "Failed to post the throttler task."); + mTask = null; + } + } + } + + private final MediaSessionCompat + .Callback mMediaSessionCallback = new MediaSessionCompat.Callback() { + @Override + public void onPlay() { + MediaNotificationController.this.onPlay( + MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION); + } + + @Override + public void onPause() { + MediaNotificationController.this.onPause( + MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION); + } + + @Override + public void onSkipToPrevious() { + MediaNotificationController.this.onMediaSessionAction( + MediaSessionAction.PREVIOUS_TRACK); + } + + @Override + public void onSkipToNext() { + MediaNotificationController.this.onMediaSessionAction(MediaSessionAction.NEXT_TRACK); + } + + @Override + public void onFastForward() { + MediaNotificationController.this.onMediaSessionAction(MediaSessionAction.SEEK_FORWARD); + } + + @Override + public void onRewind() { + MediaNotificationController.this.onMediaSessionAction(MediaSessionAction.SEEK_BACKWARD); + } + + @Override + public void onSeekTo(long pos) { + MediaNotificationController.this.onMediaSessionSeekTo(pos); + } + }; + + /** + * Finishes starting the service on O+. + * + * If startForegroundService() was called, the app MUST call startForeground on the created + * service no matter what or it will crash. + * + * @param service the {@link Service} on which {@link Context#startForegroundService()} has been + * called. + * @param notification a minimal version of the notification associated with the service. + * @return true if {@link Service#startForeground()} was called. + */ + public static boolean finishStartingForegroundServiceOnO( + Service service, ChromeNotification notification) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false; + ForegroundServiceUtils.getInstance().startForeground(service, notification.getMetadata().id, + notification.getNotification(), 0 /* foregroundServiceType */); + return true; + } + + private PendingIntent createPendingIntent(String action) { + Intent intent = mDelegate.createServiceIntent().setAction(action); + return PendingIntent.getService(getContext(), 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + + private static boolean isRunningAtLeastN() { + return (sOverrideIsRunningNForTesting != null) + ? sOverrideIsRunningNForTesting + : Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; + } + + /** + * The class containing all the information for adding a button in the notification for an + * action. + */ + private static final class MediaButtonInfo { + /** The resource ID of this media button icon. */ + public int iconResId; + + /** The resource ID of this media button description. */ + public int descriptionResId; + + /** The intent string to be fired when this media button is clicked. */ + public String intentString; + + public MediaButtonInfo(int buttonResId, int descriptionResId, String intentString) { + this.iconResId = buttonResId; + this.descriptionResId = descriptionResId; + this.intentString = intentString; + } + } + + /** An interface for separating embedder-specific logic. */ + public interface Delegate { + /** Returns an intent that will start a Service which listens to notification actions. */ + Intent createServiceIntent(); + + /** Returns the name of the embedding app. */ + String getAppName(); + + /** Returns the notification group name used to prevent automatic grouping. */ + String getNotificationGroupName(); + + /** Returns a builder suitable as a starting point for creating the notification. */ + ChromeNotificationBuilder createChromeNotificationBuilder(); + + /** Called when the Android MediaSession has been updated. */ + void onMediaSessionUpdated(MediaSessionCompat session); + + /** Called when a notification has been shown and should be logged in UMA. */ + void logNotificationShown(ChromeNotification notification); + } + + public MediaNotificationController(Delegate delegate) { + mDelegate = delegate; + + mActionToButtonInfo = new SparseArray<>(); + + mActionToButtonInfo.put(MediaSessionAction.PLAY, + new MediaButtonInfo(R.drawable.ic_play_arrow_white_36dp, + R.string.accessibility_play, ACTION_PLAY)); + mActionToButtonInfo.put(MediaSessionAction.PAUSE, + new MediaButtonInfo(R.drawable.ic_pause_white_36dp, R.string.accessibility_pause, + ACTION_PAUSE)); + mActionToButtonInfo.put(MediaSessionAction.STOP, + new MediaButtonInfo( + R.drawable.ic_stop_white_36dp, R.string.accessibility_stop, ACTION_STOP)); + mActionToButtonInfo.put(MediaSessionAction.PREVIOUS_TRACK, + new MediaButtonInfo(R.drawable.ic_skip_previous_white_36dp, + R.string.accessibility_previous_track, ACTION_PREVIOUS_TRACK)); + mActionToButtonInfo.put(MediaSessionAction.NEXT_TRACK, + new MediaButtonInfo(R.drawable.ic_skip_next_white_36dp, + R.string.accessibility_next_track, ACTION_NEXT_TRACK)); + mActionToButtonInfo.put(MediaSessionAction.SEEK_FORWARD, + new MediaButtonInfo(R.drawable.ic_fast_forward_white_36dp, + R.string.accessibility_seek_forward, ACTION_SEEK_FORWARD)); + mActionToButtonInfo.put(MediaSessionAction.SEEK_BACKWARD, + new MediaButtonInfo(R.drawable.ic_fast_rewind_white_36dp, + R.string.accessibility_seek_backward, ACTION_SEEK_BACKWARD)); + + mThrottler = new Throttler(this); + } + + /** + * Registers the started {@link Service} with the manager and creates the notification. + * + * @param service the service that was started + */ + public void onServiceStarted(Service service) { + if (mService == service) return; + + mService = service; + updateNotification(true /*serviceStarting*/, true /*shouldLogNotification*/); + } + + /** Handles the service destruction. */ + public void onServiceDestroyed() { + mService = null; + } + + public boolean processIntent(Service service, Intent intent) { + if (intent == null || mMediaNotificationInfo == null) return false; + + if (intent.getAction() == null) { + // The intent comes from {@link AppHooks#startForegroundService}. + onServiceStarted(service); + } else { + // The intent comes from the notification. In this case, {@link onServiceStarted()} + // does need to be called. + processAction(intent.getAction()); + } + return true; + } + + public void processAction(String action) { + if (ACTION_STOP.equals(action) || ACTION_SWIPE.equals(action) + || ACTION_CANCEL.equals(action)) { + onStop(MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION); + stopListenerService(); + } else if (ACTION_PLAY.equals(action)) { + onPlay(MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION); + } else if (ACTION_PAUSE.equals(action)) { + onPause(MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION); + } else if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(action)) { + onPause(MediaNotificationListener.ACTION_SOURCE_HEADSET_UNPLUG); + } else if (ACTION_PREVIOUS_TRACK.equals(action)) { + onMediaSessionAction(MediaSessionAction.PREVIOUS_TRACK); + } else if (ACTION_NEXT_TRACK.equals(action)) { + onMediaSessionAction(MediaSessionAction.NEXT_TRACK); + } else if (ACTION_SEEK_FORWARD.equals(action)) { + onMediaSessionAction(MediaSessionAction.SEEK_FORWARD); + } else if (ACTION_SEEK_BACKWARD.equals(action)) { + onMediaSessionAction(MediaSessionAction.SEEK_BACKWARD); + } + } + + @VisibleForTesting + public void onPlay(int actionSource) { + // MediaSessionCompat calls this sometimes when `mMediaNotificationInfo` + // is no longer available. It's unclear if it is a Support Library issue + // or something that isn't properly cleaned up but given that the + // crashes are rare and the fix is simple, null check was enough. + if (mMediaNotificationInfo == null || !mMediaNotificationInfo.isPaused) return; + mMediaNotificationInfo.listener.onPlay(actionSource); + } + + @VisibleForTesting + public void onPause(int actionSource) { + // MediaSessionCompat calls this sometimes when `mMediaNotificationInfo` + // is no longer available. It's unclear if it is a Support Library issue + // or something that isn't properly cleaned up but given that the + // crashes are rare and the fix is simple, null check was enough. + if (mMediaNotificationInfo == null || mMediaNotificationInfo.isPaused) return; + mMediaNotificationInfo.listener.onPause(actionSource); + } + + @VisibleForTesting + public void onStop(int actionSource) { + // MediaSessionCompat calls this sometimes when `mMediaNotificationInfo` + // is no longer available. It's unclear if it is a Support Library issue + // or something that isn't properly cleaned up but given that the + // crashes are rare and the fix is simple, null check was enough. + if (mMediaNotificationInfo == null) return; + mMediaNotificationInfo.listener.onStop(actionSource); + } + + @VisibleForTesting + public void onMediaSessionAction(int action) { + // MediaSessionCompat calls this sometimes when `mMediaNotificationInfo` + // is no longer available. It's unclear if it is a Support Library issue + // or something that isn't properly cleaned up but given that the + // crashes are rare and the fix is simple, null check was enough. + if (mMediaNotificationInfo == null) return; + mMediaNotificationInfo.listener.onMediaSessionAction(action); + } + + @VisibleForTesting + void onMediaSessionSeekTo(long pos) { + // MediaSessionCompat calls this sometimes when `mMediaNotificationInfo` + // is no longer available. It's unclear if it is a Support Library issue + // or something that isn't properly cleaned up but given that the + // crashes are rare and the fix is simple, null check was enough. + if (mMediaNotificationInfo == null) return; + mMediaNotificationInfo.listener.onMediaSessionSeekTo(pos); + } + + @VisibleForTesting + public void showNotification(MediaNotificationInfo mediaNotificationInfo) { + if (shouldIgnoreMediaNotificationInfo(mMediaNotificationInfo, mediaNotificationInfo)) { + return; + } + + mMediaNotificationInfo = mediaNotificationInfo; + + // If there's no pending service start request, don't try to start service. If there is a + // pending service start request but the service haven't started yet, only update the + // |mMediaNotificationInfo|. The service will update the notification later once it's + // started. + if (mService == null && mediaNotificationInfo.isPaused) return; + + if (mService == null) { + updateMediaSession(); + updateNotificationBuilder(); + ForegroundServiceUtils.getInstance().startForegroundService( + mDelegate.createServiceIntent()); + } else { + updateNotification(false, false); + } + } + + private static boolean shouldIgnoreMediaNotificationInfo( + MediaNotificationInfo oldInfo, MediaNotificationInfo newInfo) { + // If we don't have actions then we shouldn't display the notification. + if (newInfo.mediaSessionActions.isEmpty()) return true; + + return newInfo.equals(oldInfo) + || ((newInfo.isPaused && oldInfo != null + && newInfo.instanceId != oldInfo.instanceId)); + } + + public void clearNotification() { + mThrottler.clearPendingNotifications(); + if (mMediaNotificationInfo == null) return; + + NotificationManagerCompat.from(getContext()).cancel(mMediaNotificationInfo.id); + + if (mMediaSession != null) { + mMediaSession.setCallback(null); + mMediaSession.setActive(false); + mMediaSession.release(); + mMediaSession = null; + } + stopListenerService(); + mMediaNotificationInfo = null; + mNotificationBuilder = null; + } + + public void queueNotification(MediaNotificationInfo mediaNotificationInfo) { + mThrottler.queueNotification(mediaNotificationInfo); + } + + public void hideNotification(int instanceId) { + if (mMediaNotificationInfo == null || instanceId != mMediaNotificationInfo.instanceId) { + return; + } + clearNotification(); + } + + @VisibleForTesting + public void stopListenerService() { + if (mService == null) return; + + ForegroundServiceUtils.getInstance().stopForeground( + mService, Service.STOP_FOREGROUND_REMOVE); + mService.stopSelf(); + } + + @NonNull + @VisibleForTesting + public MediaMetadataCompat createMetadata() { + // Can't return null as {@link MediaSessionCompat#setMetadata()} will crash in some versions + // of the Android compat library. + MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder(); + if (mMediaNotificationInfo.isPrivate) return metadataBuilder.build(); + + metadataBuilder.putString( + MediaMetadataCompat.METADATA_KEY_TITLE, mMediaNotificationInfo.metadata.getTitle()); + metadataBuilder.putString( + MediaMetadataCompat.METADATA_KEY_ARTIST, mMediaNotificationInfo.origin); + + if (!TextUtils.isEmpty(mMediaNotificationInfo.metadata.getArtist())) { + metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, + mMediaNotificationInfo.metadata.getArtist()); + } + if (!TextUtils.isEmpty(mMediaNotificationInfo.metadata.getAlbum())) { + metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, + mMediaNotificationInfo.metadata.getAlbum()); + } + if (mMediaNotificationInfo.mediaSessionImage != null) { + metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, + mMediaNotificationInfo.mediaSessionImage); + } + if (mMediaNotificationInfo.mediaPosition != null) { + metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, + mMediaNotificationInfo.mediaPosition.getDuration()); + } + + return metadataBuilder.build(); + } + + @VisibleForTesting + public void updateNotification(boolean serviceStarting, boolean shouldLogNotification) { + if (mService == null) return; + + if (mMediaNotificationInfo == null) { + if (serviceStarting) { + finishStartingForegroundServiceOnO(mService, + mDelegate.createChromeNotificationBuilder().buildChromeNotification()); + ForegroundServiceUtils.getInstance().stopForeground( + mService, Service.STOP_FOREGROUND_REMOVE); + } + return; + } + updateMediaSession(); + updateNotificationBuilder(); + + ChromeNotification notification = mNotificationBuilder.buildChromeNotification(); + + // On O, finish starting the foreground service nevertheless, or Android will + // crash Chrome. + boolean finishedForegroundingService = + serviceStarting && finishStartingForegroundServiceOnO(mService, notification); + + // We keep the service as a foreground service while the media is playing. When it is not, + // the service isn't stopped but is no longer in foreground, thus at a lower priority. + // While the service is in foreground, the associated notification can't be swipped away. + // Moving it back to background allows the user to remove the notification. + if (mMediaNotificationInfo.supportsSwipeAway() && mMediaNotificationInfo.isPaused) { + ForegroundServiceUtils.getInstance().stopForeground( + mService, Service.STOP_FOREGROUND_DETACH); + NotificationManagerProxy manager = new NotificationManagerProxyImpl(getContext()); + manager.notify(notification); + } else if (!finishedForegroundingService) { + ForegroundServiceUtils.getInstance().startForeground(mService, + mMediaNotificationInfo.id, notification.getNotification(), + 0 /*foregroundServiceType*/); + } + if (shouldLogNotification) { + mDelegate.logNotificationShown(notification); + } + } + + @VisibleForTesting + public void updateNotificationBuilder() { + assert (mMediaNotificationInfo != null); + + mNotificationBuilder = mDelegate.createChromeNotificationBuilder(); + setMediaStyleLayoutForNotificationBuilder(mNotificationBuilder); + + // TODO(zqzhang): It's weird that setShowWhen() doesn't work on K. Calling setWhen() to + // force removing the time. + mNotificationBuilder.setShowWhen(false).setWhen(0); + mNotificationBuilder.setSmallIcon(mMediaNotificationInfo.notificationSmallIcon); + mNotificationBuilder.setAutoCancel(false); + mNotificationBuilder.setLocalOnly(true); + mNotificationBuilder.setGroup(mDelegate.getNotificationGroupName()); + mNotificationBuilder.setGroupSummary(true); + + if (mMediaNotificationInfo.supportsSwipeAway()) { + mNotificationBuilder.setOngoing(!mMediaNotificationInfo.isPaused); + mNotificationBuilder.setDeleteIntent(createPendingIntent(ACTION_SWIPE)); + } + + // The intent will currently only be null when using a custom tab. + // TODO(avayvod) work out what we should do in this case. See https://crbug.com/585395. + if (mMediaNotificationInfo.contentIntent != null) { + mNotificationBuilder.setContentIntent(PendingIntent.getActivity(getContext(), + mMediaNotificationInfo.instanceId, mMediaNotificationInfo.contentIntent, + PendingIntent.FLAG_UPDATE_CURRENT)); + // Set FLAG_UPDATE_CURRENT so that the intent extras is updated, otherwise the + // intent extras will stay the same for the same tab. + } + + mNotificationBuilder.setVisibility(mMediaNotificationInfo.isPrivate + ? NotificationCompat.VISIBILITY_PRIVATE + : NotificationCompat.VISIBILITY_PUBLIC); + } + + @VisibleForTesting + public void updateMediaSession() { + if (!mMediaNotificationInfo.supportsPlayPause()) return; + + if (mMediaSession == null) mMediaSession = createMediaSession(); + + activateAndroidMediaSession(mMediaNotificationInfo.instanceId); + + mDelegate.onMediaSessionUpdated(mMediaSession); + + mMediaSession.setMetadata(createMetadata()); + + mMediaSession.setPlaybackState(createPlaybackState()); + } + + @VisibleForTesting + public PlaybackStateCompat createPlaybackState() { + PlaybackStateCompat.Builder playbackStateBuilder = + new PlaybackStateCompat.Builder().setActions(computeMediaSessionActions()); + + int state = mMediaNotificationInfo.isPaused ? PlaybackStateCompat.STATE_PAUSED + : PlaybackStateCompat.STATE_PLAYING; + + if (mMediaNotificationInfo.mediaPosition != null) { + playbackStateBuilder.setState(state, mMediaNotificationInfo.mediaPosition.getPosition(), + mMediaNotificationInfo.mediaPosition.getPlaybackRate(), + mMediaNotificationInfo.mediaPosition.getLastUpdatedTime()); + } else { + playbackStateBuilder.setState( + state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f); + } + + return playbackStateBuilder.build(); + } + + private long computeMediaSessionActions() { + assert mMediaNotificationInfo != null; + + long actions = PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PAUSE; + if (mMediaNotificationInfo.mediaSessionActions.contains( + MediaSessionAction.PREVIOUS_TRACK)) { + actions |= PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; + } + if (mMediaNotificationInfo.mediaSessionActions.contains(MediaSessionAction.NEXT_TRACK)) { + actions |= PlaybackStateCompat.ACTION_SKIP_TO_NEXT; + } + if (mMediaNotificationInfo.mediaSessionActions.contains(MediaSessionAction.SEEK_FORWARD)) { + actions |= PlaybackStateCompat.ACTION_FAST_FORWARD; + } + if (mMediaNotificationInfo.mediaSessionActions.contains(MediaSessionAction.SEEK_BACKWARD)) { + actions |= PlaybackStateCompat.ACTION_REWIND; + } + if (mMediaNotificationInfo.mediaSessionActions.contains(MediaSessionAction.SEEK_TO)) { + actions |= PlaybackStateCompat.ACTION_SEEK_TO; + } + return actions; + } + + private MediaSessionCompat createMediaSession() { + MediaSessionCompat mediaSession = + new MediaSessionCompat(getContext(), mDelegate.getAppName()); + mediaSession.setCallback(mMediaSessionCallback); + mediaSession.setActive(true); + return mediaSession; + } + + /** + * Activates the media session. + * @param instanceId the instance of the notification to activate. If it doesn't match the + * active notification, this method will no-op. + */ + public void activateAndroidMediaSession(int instanceId) { + if (mMediaNotificationInfo == null) return; + if (mMediaNotificationInfo.instanceId != instanceId) return; + if (!mMediaNotificationInfo.supportsPlayPause() || mMediaNotificationInfo.isPaused) return; + if (mMediaSession == null) return; + mMediaSession.setActive(true); + } + + private void setMediaStyleLayoutForNotificationBuilder(ChromeNotificationBuilder builder) { + setMediaStyleNotificationText(builder); + if (!mMediaNotificationInfo.supportsPlayPause()) { + // Non-playback (Cast) notification will not use MediaStyle, so not + // setting the large icon is fine. + builder.setLargeIcon(null); + // Notifications in incognito shouldn't show an icon to avoid leaking information. + } else if (mMediaNotificationInfo.notificationLargeIcon != null + && !mMediaNotificationInfo.isPrivate) { + builder.setLargeIcon(mMediaNotificationInfo.notificationLargeIcon); + } else if (!isRunningAtLeastN()) { + if (mDefaultNotificationLargeIcon == null + && mMediaNotificationInfo.defaultNotificationLargeIcon != 0) { + mDefaultNotificationLargeIcon = + MediaNotificationImageUtils.downscaleIconToIdealSize( + BitmapFactory.decodeResource(getContext().getResources(), + mMediaNotificationInfo.defaultNotificationLargeIcon)); + } + builder.setLargeIcon(mDefaultNotificationLargeIcon); + } + + addNotificationButtons(builder); + } + + private void addNotificationButtons(ChromeNotificationBuilder builder) { + Set<Integer> actions = new HashSet<>(); + + // TODO(zqzhang): handle other actions when play/pause is not supported? See + // https://crbug.com/667500 + if (mMediaNotificationInfo.supportsPlayPause()) { + actions.addAll(mMediaNotificationInfo.mediaSessionActions); + if (mMediaNotificationInfo.isPaused) { + actions.remove(MediaSessionAction.PAUSE); + actions.add(MediaSessionAction.PLAY); + } else { + actions.remove(MediaSessionAction.PLAY); + actions.add(MediaSessionAction.PAUSE); + } + } + + if (mMediaNotificationInfo.supportsStop()) { + actions.add(MediaSessionAction.STOP); + } else { + actions.remove(MediaSessionAction.STOP); + } + + List<Integer> bigViewActions = computeBigViewActions(actions); + + for (int action : bigViewActions) { + MediaButtonInfo buttonInfo = mActionToButtonInfo.get(action); + builder.addAction(buttonInfo.iconResId, + getContext().getResources().getString(buttonInfo.descriptionResId), + createPendingIntent(buttonInfo.intentString)); + } + + // Only apply MediaStyle when NotificationInfo supports play/pause. + if (mMediaNotificationInfo.supportsPlayPause()) { + builder.setMediaStyle(mMediaSession, computeCompactViewActionIndices(bigViewActions), + createPendingIntent(ACTION_CANCEL), true); + } + } + + private void setMediaStyleNotificationText(ChromeNotificationBuilder builder) { + if (mMediaNotificationInfo.isPrivate) { + // Notifications in incognito shouldn't show what is playing to avoid leaking + // information. + if (isRunningAtLeastN()) { + builder.setContentTitle(getContext().getResources().getString( + R.string.media_notification_incognito)); + builder.setSubText( + getContext().getResources().getString(R.string.notification_incognito_tab)); + } else { + // App name is automatically added to the title from Android N, + // but needs to be added explicitly for prior versions. + builder.setContentTitle(mDelegate.getAppName()) + .setContentText(getContext().getResources().getString( + R.string.media_notification_incognito)); + } + return; + } + + builder.setContentTitle(mMediaNotificationInfo.metadata.getTitle()); + String artistAndAlbumText = getArtistAndAlbumText(mMediaNotificationInfo.metadata); + if (isRunningAtLeastN() || !artistAndAlbumText.isEmpty()) { + builder.setContentText(artistAndAlbumText); + builder.setSubText(mMediaNotificationInfo.origin); + } else { + // Leaving ContentText empty looks bad, so move origin up to the ContentText. + builder.setContentText(mMediaNotificationInfo.origin); + } + } + + private static String getArtistAndAlbumText(MediaMetadata metadata) { + String artist = (metadata.getArtist() == null) ? "" : metadata.getArtist(); + String album = (metadata.getAlbum() == null) ? "" : metadata.getAlbum(); + if (artist.isEmpty() || album.isEmpty()) { + return artist + album; + } + return artist + " - " + album; + } + + /** + * Compute the actions to be shown in BigView media notification. + * + * The method assumes STOP cannot coexist with switch track actions and seeking actions. It also + * assumes PLAY and PAUSE cannot coexist. + */ + private static List<Integer> computeBigViewActions(Set<Integer> actions) { + // STOP cannot coexist with switch track actions and seeking actions. + assert !actions.contains(MediaSessionAction.STOP) + || !(actions.contains(MediaSessionAction.PREVIOUS_TRACK) + && actions.contains(MediaSessionAction.NEXT_TRACK) + && actions.contains(MediaSessionAction.SEEK_BACKWARD) + && actions.contains(MediaSessionAction.SEEK_FORWARD)); + // PLAY and PAUSE cannot coexist. + assert !actions.contains(MediaSessionAction.PLAY) + || !actions.contains(MediaSessionAction.PAUSE); + + int[] actionByOrder = { + MediaSessionAction.PREVIOUS_TRACK, + MediaSessionAction.SEEK_BACKWARD, + MediaSessionAction.PLAY, + MediaSessionAction.PAUSE, + MediaSessionAction.SEEK_FORWARD, + MediaSessionAction.NEXT_TRACK, + MediaSessionAction.STOP, + }; + + // Sort the actions based on the expected ordering in the UI. + List<Integer> sortedActions = new ArrayList<>(); + for (int action : actionByOrder) { + if (actions.contains(action)) sortedActions.add(action); + } + + // There can't be move actions than BIG_VIEW_ACTIONS_COUNT. We do this check after we have + // sorted the actions since there may be more actions that we do not support. + assert sortedActions.size() <= BIG_VIEW_ACTIONS_COUNT; + + return sortedActions; + } + + /** + * Compute the actions to be shown in CompactView media notification. + * + * The method assumes STOP cannot coexist with switch track actions and seeking actions. It also + * assumes PLAY and PAUSE cannot coexist. + * + * Actions in pairs are preferred if there are more actions than |COMPACT_VIEW_ACTIONS_COUNT|. + */ + @VisibleForTesting + static int[] computeCompactViewActionIndices(List<Integer> actions) { + // STOP cannot coexist with switch track actions and seeking actions. + assert !actions.contains(MediaSessionAction.STOP) + || !(actions.contains(MediaSessionAction.PREVIOUS_TRACK) + && actions.contains(MediaSessionAction.NEXT_TRACK) + && actions.contains(MediaSessionAction.SEEK_BACKWARD) + && actions.contains(MediaSessionAction.SEEK_FORWARD)); + // PLAY and PAUSE cannot coexist. + assert !actions.contains(MediaSessionAction.PLAY) + || !actions.contains(MediaSessionAction.PAUSE); + + if (actions.size() <= COMPACT_VIEW_ACTIONS_COUNT) { + // If the number of actions is less than |COMPACT_VIEW_ACTIONS_COUNT|, just return an + // array of 0, 1, ..., |actions.size()|-1. + int[] actionsArray = new int[actions.size()]; + for (int i = 0; i < actions.size(); ++i) actionsArray[i] = i; + return actionsArray; + } + + if (actions.contains(MediaSessionAction.STOP)) { + List<Integer> compactActions = new ArrayList<>(); + if (actions.contains(MediaSessionAction.PLAY)) { + compactActions.add(actions.indexOf(MediaSessionAction.PLAY)); + } + compactActions.add(actions.indexOf(MediaSessionAction.STOP)); + return CollectionUtil.integerListToIntArray(compactActions); + } + + int[] actionsArray = new int[COMPACT_VIEW_ACTIONS_COUNT]; + if (actions.contains(MediaSessionAction.PREVIOUS_TRACK) + && actions.contains(MediaSessionAction.NEXT_TRACK)) { + actionsArray[0] = actions.indexOf(MediaSessionAction.PREVIOUS_TRACK); + if (actions.contains(MediaSessionAction.PLAY)) { + actionsArray[1] = actions.indexOf(MediaSessionAction.PLAY); + } else { + actionsArray[1] = actions.indexOf(MediaSessionAction.PAUSE); + } + actionsArray[2] = actions.indexOf(MediaSessionAction.NEXT_TRACK); + return actionsArray; + } + + assert actions.contains(MediaSessionAction.SEEK_BACKWARD) + && actions.contains(MediaSessionAction.SEEK_FORWARD); + actionsArray[0] = actions.indexOf(MediaSessionAction.SEEK_BACKWARD); + if (actions.contains(MediaSessionAction.PLAY)) { + actionsArray[1] = actions.indexOf(MediaSessionAction.PLAY); + } else { + actionsArray[1] = actions.indexOf(MediaSessionAction.PAUSE); + } + actionsArray[2] = actions.indexOf(MediaSessionAction.SEEK_FORWARD); + + return actionsArray; + } + + private static Context getContext() { + return ContextUtils.getApplicationContext(); + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationImageUtils.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationImageUtils.java new file mode 100644 index 00000000000..d5ce90feedb --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationImageUtils.java @@ -0,0 +1,74 @@ +// Copyright 2020 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.components.browser_ui.media; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; + +import androidx.annotation.Nullable; + +import org.chromium.base.SysUtils; + +/** A collection of utilities and constants for the images used in MediaSession notifications. */ +public class MediaNotificationImageUtils { + public static final int MINIMAL_MEDIA_IMAGE_SIZE_PX = 114; + + // The media artwork image resolution on high-end devices. + private static final int HIGH_IMAGE_SIZE_PX = 512; + + // The media artwork image resolution on high-end devices. + private static final int LOW_IMAGE_SIZE_PX = 256; + + /** + * Downscale |icon| for display in the notification if needed. Returns null if |icon| is null. + * If |icon| is larger than {@link getIdealMediaImageSize()}, scale it down to + * {@link getIdealMediaImageSize()} and return. Otherwise return the original |icon|. + * @param icon The icon to be scaled. + */ + @Nullable + public static Bitmap downscaleIconToIdealSize(@Nullable Bitmap icon) { + if (icon == null) return null; + + int targetSize = getIdealMediaImageSize(); + + Matrix m = new Matrix(); + int dominantLength = Math.max(icon.getWidth(), icon.getHeight()); + + if (dominantLength < getIdealMediaImageSize()) return icon; + + // Move the center to (0,0). + m.postTranslate(icon.getWidth() / -2.0f, icon.getHeight() / -2.0f); + // Scale to desired size. + float scale = 1.0f * targetSize / dominantLength; + m.postScale(scale, scale); + // Move to the desired place. + m.postTranslate(targetSize / 2.0f, targetSize / 2.0f); + + // Draw the image. + Bitmap paddedBitmap = Bitmap.createBitmap(targetSize, targetSize, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(paddedBitmap); + Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG); + canvas.drawBitmap(icon, m, paint); + return paddedBitmap; + } + + /** + * @return The ideal size of the media image. + */ + public static int getIdealMediaImageSize() { + return SysUtils.isLowEndDevice() ? LOW_IMAGE_SIZE_PX : HIGH_IMAGE_SIZE_PX; + } + + /** + * @param icon The icon to be checked. + * @return Whether |icon| is suitable as the media image, i.e. bigger than the minimal size. + */ + public static boolean isBitmapSuitableAsMediaImage(Bitmap icon) { + return icon != null && icon.getWidth() >= MINIMAL_MEDIA_IMAGE_SIZE_PX + && icon.getHeight() >= MINIMAL_MEDIA_IMAGE_SIZE_PX; + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationInfo.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationInfo.java new file mode 100644 index 00000000000..855ed9a50e2 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationInfo.java @@ -0,0 +1,354 @@ +// 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.components.browser_ui.media; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import org.chromium.services.media_session.MediaMetadata; +import org.chromium.services.media_session.MediaPosition; + +import java.util.HashSet; +import java.util.Set; + +/** + * Exposes information about the current media notification to the external clients. + */ +public class MediaNotificationInfo { + // Bits defining various user actions supported by the media notification. + + /** + * If set, play/pause controls are shown and handled via notification UI and MediaSession. + */ + public static final int ACTION_PLAY_PAUSE = 1 << 0; + + /** + * If set, a stop button is shown and handled via the notification UI. + */ + public static final int ACTION_STOP = 1 << 1; + + /** + * If set, a user can swipe the notification away when it's paused. + * If notification swipe is not supported, it will behave like {@link #ACTION_STOP}. + */ + public static final int ACTION_SWIPEAWAY = 1 << 2; + + /** + * A value that represents an invalid ID. + */ + public static final int INVALID_ID = -1; + + /** + * Use this class to construct an instance of {@link MediaNotificationInfo}. + */ + public static final class Builder { + private MediaMetadata mMetadata; + private boolean mIsPaused; + private String mOrigin = ""; + private int mInstanceId = INVALID_ID; + private boolean mIsPrivate = true; + private int mNotificationSmallIcon; + private Bitmap mNotificationLargeIcon; + private int mDefaultNotificationLargeIcon; + private Bitmap mMediaSessionImage; + private int mActions = ACTION_PLAY_PAUSE | ACTION_SWIPEAWAY; + private int mId = INVALID_ID; + private Intent mContentIntent; + private MediaNotificationListener mListener; + private Set<Integer> mMediaSessionActions; + private @Nullable MediaPosition mMediaPosition; + + /** + * Initializes the builder with the default values. + */ + public Builder() {} + + public MediaNotificationInfo build() { + assert mMetadata != null; + assert mOrigin != null; + assert mListener != null; + + return new MediaNotificationInfo(mMetadata, mIsPaused, mOrigin, mInstanceId, mIsPrivate, + mNotificationSmallIcon, mNotificationLargeIcon, mDefaultNotificationLargeIcon, + mMediaSessionImage, mActions, mId, mContentIntent, mListener, + mMediaSessionActions, mMediaPosition); + } + + public Builder setMetadata(MediaMetadata metadata) { + mMetadata = metadata; + return this; + } + + public Builder setPaused(boolean isPaused) { + mIsPaused = isPaused; + return this; + } + + public Builder setOrigin(String origin) { + mOrigin = origin; + return this; + } + + public Builder setInstanceId(int instanceId) { + mInstanceId = instanceId; + return this; + } + + public Builder setPrivate(boolean isPrivate) { + mIsPrivate = isPrivate; + return this; + } + + public Builder setNotificationSmallIcon(int icon) { + mNotificationSmallIcon = icon; + return this; + } + + public Builder setNotificationLargeIcon(Bitmap icon) { + mNotificationLargeIcon = icon; + return this; + } + + public Builder setDefaultNotificationLargeIcon(int icon) { + mDefaultNotificationLargeIcon = icon; + return this; + } + + public Builder setMediaSessionImage(Bitmap image) { + mMediaSessionImage = image; + return this; + } + + public Builder setActions(int actions) { + mActions = actions; + return this; + } + + public Builder setId(int id) { + mId = id; + return this; + } + + public Builder setContentIntent(Intent intent) { + mContentIntent = intent; + return this; + } + + public Builder setListener(MediaNotificationListener listener) { + mListener = listener; + return this; + } + + public Builder setMediaSessionActions(Set<Integer> actions) { + mMediaSessionActions = actions; + return this; + } + + public Builder setMediaPosition(@Nullable MediaPosition position) { + mMediaPosition = position; + return this; + } + } + + /** + * The bitset defining user actions handled by the notification. + */ + private final int mActions; + + /** + * The metadata associated with the media. + */ + public final MediaMetadata metadata; + + /** + * The current state of the media, paused or not. + */ + public final boolean isPaused; + + /** + * The origin of the tab containing the media. + */ + public final String origin; + + /** + * An identifier that helps distinguish different instances of the same type of media + * notification. The {@link id} is shared by different MediaNotificationInfo instances for the + * same media type, but this identifier provides an extra layer of differentiation. In Chrome, + * for example, this corresponds to the source tab. + */ + public final int instanceId; + + /** + * Whether the media notification should be considered as private. + */ + public final boolean isPrivate; + + /** + * The id of the notification small icon from R.drawable. + */ + public final int notificationSmallIcon; + + /** + * The Bitmap resource used as the notification large icon. + */ + public final Bitmap notificationLargeIcon; + + /** + * The id of the default notification large icon from R.drawable. + */ + public final int defaultNotificationLargeIcon; + + /** + * The Bitmap resource used for Android MediaSession image, which will be used on lock screen + * and wearable devices. + */ + public final Bitmap mediaSessionImage; + + /** + * The id to use for the Android Notification. + */ + public final int id; + + /** + * The intent to send when the notification is selected. + */ + public final Intent contentIntent; + + /** + * The listener for the control events. + */ + public final MediaNotificationListener listener; + + /** + * The actions enabled in MediaSession. + */ + public final Set<Integer> mediaSessionActions; + + /** + * The current position of the media session. + */ + public final @Nullable MediaPosition mediaPosition; + + /** + * @return if play/pause actions are supported by this notification. + */ + public boolean supportsPlayPause() { + return (mActions & ACTION_PLAY_PAUSE) != 0; + } + + /** + * @return if stop action is supported by this notification. + */ + public boolean supportsStop() { + return (mActions & ACTION_STOP) != 0; + } + + /** + * @return if notification should be dismissable by swiping it away when paused. + */ + public boolean supportsSwipeAway() { + return (mActions & ACTION_SWIPEAWAY) != 0; + } + + /** + * Create a new MediaNotificationInfo. + * @param metadata The metadata associated with the media. + * @param isPaused The current state of the media, paused or not. + * @param origin The origin of the tab containing the media. + * @param instanceId The id of the tab containing the media. + * @param isPrivate Whether the media notification should be considered as private. + * @param notificationSmallIcon The small icon used in the notification. + * @param notificationLargeIcon The large icon used in the notification. + * @param defaultNotificationLargeIcon The fallback large icon when |notificationLargeIcon| is + * improper to use. + * @param mediaSessionImage The artwork image to be used in Android MediaSession. + * @param actions The actions supported in this notification. + * @param id The id of this notification, which is used for distinguishing media playback, cast + * and media remote. + * @param contentIntent The intent to send when the notification is selected. + * @param listener The listener for the control events. + * @param mediaSessionActions The actions supported by the page. + * @param mediaPosition The current position of the media. + */ + private MediaNotificationInfo(MediaMetadata metadata, boolean isPaused, String origin, + int instanceId, boolean isPrivate, int notificationSmallIcon, + Bitmap notificationLargeIcon, int defaultNotificationLargeIcon, + Bitmap mediaSessionImage, int actions, int id, Intent contentIntent, + MediaNotificationListener listener, Set<Integer> mediaSessionActions, + @Nullable MediaPosition mediaPosition) { + this.metadata = metadata; + this.isPaused = isPaused; + this.origin = origin; + assert instanceId != INVALID_ID; + this.instanceId = instanceId; + this.isPrivate = isPrivate; + this.notificationSmallIcon = notificationSmallIcon; + this.notificationLargeIcon = notificationLargeIcon; + this.defaultNotificationLargeIcon = defaultNotificationLargeIcon; + this.mediaSessionImage = mediaSessionImage; + this.mActions = actions; + assert id != INVALID_ID; + this.id = id; + this.contentIntent = contentIntent; + this.listener = listener; + this.mediaSessionActions = (mediaSessionActions != null) + ? new HashSet<Integer>(mediaSessionActions) + : new HashSet<Integer>(); + this.mediaPosition = mediaPosition; + } + + @Override + @SuppressWarnings("ReferenceEquality") + public boolean equals(Object obj) { + if (obj == this) return true; + if (!(obj instanceof MediaNotificationInfo)) return false; + + MediaNotificationInfo other = (MediaNotificationInfo) obj; + return isPaused == other.isPaused && isPrivate == other.isPrivate + && instanceId == other.instanceId + && notificationSmallIcon == other.notificationSmallIcon + && (notificationLargeIcon == other.notificationLargeIcon + || (notificationLargeIcon != null + && notificationLargeIcon.sameAs(other.notificationLargeIcon))) + && defaultNotificationLargeIcon == other.defaultNotificationLargeIcon + && mediaSessionImage == other.mediaSessionImage && mActions == other.mActions + && id == other.id + && (metadata == other.metadata + || (metadata != null && metadata.equals(other.metadata))) + && TextUtils.equals(origin, other.origin) + && (contentIntent == other.contentIntent + || (contentIntent != null && contentIntent.equals(other.contentIntent))) + && (listener == other.listener + || (listener != null && listener.equals(other.listener))) + && (mediaSessionActions == other.mediaSessionActions + || (mediaSessionActions != null + && mediaSessionActions.equals(other.mediaSessionActions))) + && mediaPosition == other.mediaPosition; + } + + @Override + public int hashCode() { + int result = isPaused ? 1 : 0; + result = 31 * result + (isPrivate ? 1 : 0); + result = 31 * result + (metadata == null ? 0 : metadata.hashCode()); + result = 31 * result + (origin == null ? 0 : origin.hashCode()); + result = 31 * result + (contentIntent == null ? 0 : contentIntent.hashCode()); + result = 31 * result + instanceId; + result = 31 * result + notificationSmallIcon; + result = 31 * result + + (notificationLargeIcon == null ? 0 : notificationLargeIcon.hashCode()); + result = 31 * result + defaultNotificationLargeIcon; + result = 31 * result + (mediaSessionImage == null ? 0 : mediaSessionImage.hashCode()); + result = 31 * result + mActions; + result = 31 * result + id; + result = 31 * result + listener.hashCode(); + result = 31 * result + mediaSessionActions.hashCode(); + result = 31 * result + mediaPosition.hashCode(); + return result; + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationListener.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationListener.java new file mode 100644 index 00000000000..f4ae70e4e24 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationListener.java @@ -0,0 +1,57 @@ +// 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.components.browser_ui.media; + +/** + * Interface for classes that need to be notified about media events. + */ +public interface MediaNotificationListener { + /** + * The media action was caused by direct interaction with the notification. + */ + public static final int ACTION_SOURCE_MEDIA_NOTIFICATION = 1000; + + /** + * The media action was received via the MediaSession Android API, e.g. a headset, a watch, etc. + */ + public static final int ACTION_SOURCE_MEDIA_SESSION = 1001; + + /** + * The media action was received by unplugging the headset, + * which broadcasts an ACTION_AUDIO_BECOMING_NOISY intent. + */ + public static final int ACTION_SOURCE_HEADSET_UNPLUG = 1002; + + /** + * Called when the user wants to resume the playback. + * @param actionSource The source the listener got the action from. + */ + void onPlay(int actionSource); + + /** + * Called when the user wants to pause the playback. + * @param actionSource The source the listener got the action from. + */ + void onPause(int actionSource); + + /** + * Called when the user wants to stop the playback. + * @param actionSource The source the listener got the action from. + */ + void onStop(int actionSource); + + /** + * Called when the user performed one of the media actions (like fast forward or next track) + * supported by MediaSession. + * @param action The kind of the initated action. + */ + void onMediaSessionAction(int action); + + /** + * Called when the user performed a seek action through Media Session. + * @param action The position to seek to in ms. + */ + void onMediaSessionSeekTo(long pos); +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationUma.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationUma.java new file mode 100644 index 00000000000..83c25162906 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationUma.java @@ -0,0 +1,47 @@ +// Copyright 2016 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.components.browser_ui.media; + +import android.content.Intent; + +import androidx.annotation.IntDef; + +import org.chromium.base.metrics.RecordHistogram; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Helper class to record which kind of media notifications does the user click to go back to + * Chrome. + */ +public class MediaNotificationUma { + @IntDef({Source.INVALID, Source.MEDIA, Source.PRESENTATION, Source.MEDIA_FLING}) + @Retention(RetentionPolicy.SOURCE) + public @interface Source { + int INVALID = -1; + int MEDIA = 0; + int PRESENTATION = 1; + int MEDIA_FLING = 2; + int NUM_ENTRIES = 3; + } + + public static final String INTENT_EXTRA_NAME = + "org.chromium.chrome.browser.metrics.MediaNotificationUma.EXTRA_CLICK_SOURCE"; + + /** + * Record the UMA as specified by {@link intent}. The {@link intent} should contain intent extra + * of name {@link INTENT_EXTRA_NAME} indicating the type. + * @param intent The intent starting the activity. + */ + public static void recordClickSource(Intent intent) { + if (intent == null) return; + @Source + int source = intent.getIntExtra(INTENT_EXTRA_NAME, Source.INVALID); + if (source == Source.INVALID || source >= Source.NUM_ENTRIES) return; + RecordHistogram.recordEnumeratedHistogram( + "Media.Notification.Click", source, Source.NUM_ENTRIES); + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionHelper.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionHelper.java new file mode 100644 index 00000000000..7c9422e2248 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionHelper.java @@ -0,0 +1,573 @@ +// Copyright 2020 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.components.browser_ui.media; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.media.AudioManager; +import android.os.Build; +import android.os.Handler; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.chromium.base.SysUtils; +import org.chromium.components.url_formatter.UrlFormatter; +import org.chromium.content_public.browser.MediaSession; +import org.chromium.content_public.browser.MediaSessionObserver; +import org.chromium.content_public.browser.NavigationHandle; +import org.chromium.content_public.browser.WebContents; +import org.chromium.content_public.browser.WebContentsObserver; +import org.chromium.media_session.mojom.MediaSessionAction; +import org.chromium.services.media_session.MediaImage; +import org.chromium.services.media_session.MediaMetadata; +import org.chromium.services.media_session.MediaPosition; +import org.chromium.ui.base.WindowAndroid; + +import java.util.List; +import java.util.Set; + +/** + * Glue code that relays events from the {@link org.chromium.content.browser.MediaSession} for a + * WebContents to a delegate (ultimately, to {@link MediaNotificationController}). + */ +public class MediaSessionHelper implements MediaImageCallback { + private static final String TAG = "MediaSession"; + + private static final String UNICODE_PLAY_CHARACTER = "\u25B6"; + @VisibleForTesting + public static final int HIDE_NOTIFICATION_DELAY_MILLIS = 2500; + + private Delegate mDelegate; + private WebContents mWebContents; + @VisibleForTesting + public WebContentsObserver mWebContentsObserver; + @VisibleForTesting + public MediaSessionObserver mMediaSessionObserver; + private MediaImageManager mMediaImageManager; + private Bitmap mPageMediaImage; + @VisibleForTesting + public Bitmap mFavicon; + private Bitmap mCurrentMediaImage; + private String mOrigin; + private int mPreviousVolumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE; + @VisibleForTesting + public MediaNotificationInfo.Builder mNotificationInfoBuilder; + // The fallback title if |mPageMetadata| is null or its title is empty. + private String mFallbackTitle; + // Set to true if favicon update callback was called at least once. + private boolean mMaybeHasFavicon; + // The metadata set by the page. + private MediaMetadata mPageMetadata; + // The currently showing metadata. + private MediaMetadata mCurrentMetadata; + private Set<Integer> mMediaSessionActions; + private @Nullable MediaPosition mMediaPosition; + private Handler mHandler; + // The delayed task to hide notification. Hiding notification can be immediate or delayed. + // Delayed hiding will schedule this delayed task to |mHandler|. The task will be canceled when + // showing or immediate hiding. + private Runnable mHideNotificationDelayedTask; + + // Used to override the MediaSession object get from WebContents. This is to work around the + // static getter {@link MediaSession#fromWebContents()}. + @VisibleForTesting + public static MediaSession sOverriddenMediaSession; + + private MediaNotificationListener mControlsListener = new MediaNotificationListener() { + @Override + public void onPlay(int actionSource) { + if (isNotificationHidingOrHidden()) return; + + MediaSessionUma.recordPlay( + MediaSessionHelper.convertMediaActionSourceToUMA(actionSource)); + + if (mMediaSessionObserver.getMediaSession() == null) return; + + mMediaSessionObserver.getMediaSession().resume(); + } + + @Override + public void onPause(int actionSource) { + if (isNotificationHidingOrHidden()) return; + + MediaSessionUma.recordPause( + MediaSessionHelper.convertMediaActionSourceToUMA(actionSource)); + + if (mMediaSessionObserver.getMediaSession() == null) return; + + mMediaSessionObserver.getMediaSession().suspend(); + } + + @Override + public void onStop(int actionSource) { + if (isNotificationHidingOrHidden()) return; + + MediaSessionUma.recordStop( + MediaSessionHelper.convertMediaActionSourceToUMA(actionSource)); + + if (mMediaSessionObserver.getMediaSession() != null) { + mMediaSessionObserver.getMediaSession().stop(); + } + } + + @Override + public void onMediaSessionAction(int action) { + if (!MediaSessionAction.isKnownValue(action)) return; + if (mMediaSessionObserver != null) { + mMediaSessionObserver.getMediaSession().didReceiveAction(action); + } + } + + @Override + public void onMediaSessionSeekTo(long pos) { + if (mMediaSessionObserver == null) return; + mMediaSessionObserver.getMediaSession().seekTo(pos); + } + }; + + private void hideNotificationDelayed() { + if (mWebContentsObserver == null) return; + if (mHideNotificationDelayedTask != null) return; + + mHideNotificationDelayedTask = new Runnable() { + @Override + public void run() { + mHideNotificationDelayedTask = null; + hideNotificationInternal(); + } + }; + mHandler.postDelayed(mHideNotificationDelayedTask, HIDE_NOTIFICATION_DELAY_MILLIS); + + mNotificationInfoBuilder = null; + mFavicon = null; + } + + private void hideNotificationImmediately() { + if (mWebContentsObserver == null) return; + if (mHideNotificationDelayedTask != null) { + mHandler.removeCallbacks(mHideNotificationDelayedTask); + mHideNotificationDelayedTask = null; + } + + hideNotificationInternal(); + mNotificationInfoBuilder = null; + } + + /** + * This method performs the common steps for hiding the notification. It should only be called + * by {@link #hideNotificationDelayed()} and {@link #hideNotificationImmediately()}. + */ + private void hideNotificationInternal() { + mDelegate.hideMediaNotification(); + Activity activity = getActivity(); + if (activity != null) { + activity.setVolumeControlStream(mPreviousVolumeControlStream); + } + } + + private void showNotification() { + assert mNotificationInfoBuilder != null; + if (mHideNotificationDelayedTask != null) { + mHandler.removeCallbacks(mHideNotificationDelayedTask); + mHideNotificationDelayedTask = null; + } + mDelegate.showMediaNotification(mNotificationInfoBuilder.build()); + } + + private MediaSessionObserver createMediaSessionObserver(MediaSession mediaSession) { + return new MediaSessionObserver(mediaSession) { + @Override + public void mediaSessionDestroyed() { + hideNotificationImmediately(); + cleanupMediaSessionObserver(); + } + + @Override + public void mediaSessionStateChanged(boolean isControllable, boolean isPaused) { + if (!isControllable) { + hideNotificationDelayed(); + return; + } + + Intent contentIntent = mDelegate.createBringTabToFrontIntent(); + if (contentIntent != null) { + contentIntent.putExtra(MediaNotificationUma.INTENT_EXTRA_NAME, + MediaNotificationUma.Source.MEDIA); + } + + if (mFallbackTitle == null) mFallbackTitle = sanitizeMediaTitle(mOrigin); + + mCurrentMetadata = getMetadata(); + mCurrentMediaImage = getCachedNotificationImage(); + mNotificationInfoBuilder = + mDelegate.createMediaNotificationInfoBuilder() + .setMetadata(mCurrentMetadata) + .setPaused(isPaused) + .setOrigin(mOrigin) + .setPrivate(mWebContents.isIncognito()) + .setNotificationSmallIcon(R.drawable.audio_playing) + .setNotificationLargeIcon(mCurrentMediaImage) + .setMediaSessionImage(mPageMediaImage) + .setActions(MediaNotificationInfo.ACTION_PLAY_PAUSE + | MediaNotificationInfo.ACTION_SWIPEAWAY) + .setContentIntent(contentIntent) + .setListener(mControlsListener) + .setMediaSessionActions(mMediaSessionActions) + .setMediaPosition(mMediaPosition); + + // Show a default icon in incognito contents, as they don't show the media icon. + // Also show a default icon if we won't get a favicon from {@link mDelegate}. If the + // delegate will pass a favicon later, show nothing for now; we expect the favicon + // to arrive quickly. + if (mWebContents.isIncognito() + || (mCurrentMediaImage == null && !fetchLargeFaviconImage())) { + mNotificationInfoBuilder.setDefaultNotificationLargeIcon( + R.drawable.audio_playing_square); + } + showNotification(); + Activity activity = getActivity(); + if (activity != null) { + activity.setVolumeControlStream(AudioManager.STREAM_MUSIC); + } + } + + @Override + public void mediaSessionMetadataChanged(MediaMetadata metadata) { + mPageMetadata = metadata; + updateNotificationMetadata(); + } + + @Override + public void mediaSessionActionsChanged(Set<Integer> actions) { + mMediaSessionActions = actions; + updateNotificationActions(); + } + + @Override + public void mediaSessionArtworkChanged(List<MediaImage> images) { + mMediaImageManager.downloadImage(images, MediaSessionHelper.this); + updateNotificationMetadata(); + } + + @Override + public void mediaSessionPositionChanged(@Nullable MediaPosition position) { + mMediaPosition = position; + updateNotificationPosition(); + } + }; + } + + public void setWebContents(@NonNull WebContents webContents) { + if (mWebContents == webContents) return; + + mWebContents = webContents; + + if (mWebContentsObserver != null) mWebContentsObserver.destroy(); + mWebContentsObserver = new WebContentsObserver(webContents) { + @Override + public void didFinishNavigation(NavigationHandle navigation) { + if (!navigation.hasCommitted() || !navigation.isInMainFrame() + || navigation.isSameDocument()) { + return; + } + + mOrigin = UrlFormatter.formatUrlForDisplayOmitSchemeOmitTrivialSubdomains( + webContents.getVisibleUrl().getOrigin().getSpec()); + mFavicon = null; + mPageMediaImage = null; + mPageMetadata = null; + // |mCurrentMetadata| selects either |mPageMetadata| or |mFallbackTitle|. As + // there is no guarantee {@link #titleWasSet()} will be called before or + // after this method, |mFallbackTitle| is not reset in this callback, i.e. + // relying solely on + // {@link #titleWasSet()}. The following assignment is to keep + // |mCurrentMetadata| up to date as |mPageMetadata| may have changed. + mCurrentMetadata = getMetadata(); + mMediaSessionActions = null; + + if (isNotificationHidingOrHidden()) return; + + mNotificationInfoBuilder.setOrigin(mOrigin); + mNotificationInfoBuilder.setNotificationLargeIcon(mFavicon); + mNotificationInfoBuilder.setMediaSessionImage(mPageMediaImage); + mNotificationInfoBuilder.setMetadata(mCurrentMetadata); + mNotificationInfoBuilder.setMediaSessionActions(mMediaSessionActions); + showNotification(); + } + + @Override + public void titleWasSet(String title) { + String newFallbackTitle = sanitizeMediaTitle(title); + if (!TextUtils.equals(mFallbackTitle, newFallbackTitle)) { + mFallbackTitle = newFallbackTitle; + updateNotificationMetadata(); + } + } + + @Override + public void wasShown() { + mDelegate.activateAndroidMediaSession(); + } + }; + + MediaSession mediaSession = getMediaSession(webContents); + if (mMediaSessionObserver != null + && mediaSession == mMediaSessionObserver.getMediaSession()) { + return; + } + + cleanupMediaSessionObserver(); + mMediaImageManager.setWebContents(webContents); + if (mediaSession != null) { + mMediaSessionObserver = createMediaSessionObserver(mediaSession); + } + } + + private void cleanupMediaSessionObserver() { + if (mMediaSessionObserver == null) return; + mMediaSessionObserver.stopObserving(); + mMediaSessionObserver = null; + mMediaSessionActions = null; + } + + /** An interface for dispatching embedder-specific behavior. */ + public interface Delegate { + /** Returns an intent that brings the associated web contents to the front. */ + Intent createBringTabToFrontIntent(); + + /** + * Called to asynchronously fetch a larger favicon image. + * + * Normal, smaller favicons are passed in automatically. This call triggers lookup of a + * larger icon, which will also be passed in via {@link updateFavicon()}, or not at all if + * this method returns false. + * @return true if the favicon will be updated. + */ + boolean fetchLargeFaviconImage(); + + /** + * Creates a {@link MediaNotificationInfo.Builder} with basic embedder-specific + * initialization. + */ + public MediaNotificationInfo.Builder createMediaNotificationInfoBuilder(); + + /** Shows a notification with the given metadata. */ + void showMediaNotification(MediaNotificationInfo notificationInfo); + + /** Hides the active notification. */ + void hideMediaNotification(); + + /** Activates the Android MediaSession. */ + void activateAndroidMediaSession(); + } + + public MediaSessionHelper(@NonNull WebContents webContents, @NonNull Delegate delegate) { + mDelegate = delegate; + mMediaImageManager = + new MediaImageManager(MediaNotificationImageUtils.MINIMAL_MEDIA_IMAGE_SIZE_PX, + MediaNotificationImageUtils.getIdealMediaImageSize()); + mHandler = new Handler(); + setWebContents(webContents); + + Activity activity = getActivity(); + if (activity != null) { + mPreviousVolumeControlStream = activity.getVolumeControlStream(); + } + } + + /** + * Called when this object should no longer manage a media session because owning code no longer + * requires it. + */ + public void destroy() { + cleanupMediaSessionObserver(); + hideNotificationImmediately(); + if (mWebContentsObserver != null) mWebContentsObserver.destroy(); + mWebContentsObserver = null; + } + + /** + * Removes all the leading/trailing white spaces and the quite common unicode play character. + * It improves the visibility of the title in the notification. + * + * @param title The original tab title, e.g. " ▶ Foo - Bar " + * @return The sanitized tab title, e.g. "Foo - Bar" + */ + private String sanitizeMediaTitle(String title) { + title = title.trim(); + return title.startsWith(UNICODE_PLAY_CHARACTER) ? title.substring(1).trim() : title; + } + + /** + * Converts the {@link MediaNotificationListener} action source enum into the + * {@link MediaSessionUma} one to ensure matching the histogram values. + * @param source the source id, must be one of the ACTION_SOURCE_* constants defined in the + * {@link MediaNotificationListener} interface. + * @return the corresponding histogram value. + */ + public static @MediaSessionUma.MediaSessionActionSource int convertMediaActionSourceToUMA( + int source) { + if (source == MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION) { + return MediaSessionUma.MediaSessionActionSource.MEDIA_NOTIFICATION; + } else if (source == MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION) { + return MediaSessionUma.MediaSessionActionSource.MEDIA_SESSION; + } else if (source == MediaNotificationListener.ACTION_SOURCE_HEADSET_UNPLUG) { + return MediaSessionUma.MediaSessionActionSource.HEADSET_UNPLUG; + } + + assert false; + return MediaSessionUma.MediaSessionActionSource.NUM_ENTRIES; + } + + private Activity getActivity() { + assert mWebContents != null; + WindowAndroid windowAndroid = mWebContents.getTopLevelNativeWindow(); + if (windowAndroid == null) return null; + + return windowAndroid.getActivity().get(); + } + + /** Returns true if a large favicon might be found. */ + private boolean fetchLargeFaviconImage() { + // The page does not have a favicon yet to fetch since onFaviconUpdated was never called. + // Don't waste time trying to find it. + if (!mMaybeHasFavicon) return false; + + return mDelegate.fetchLargeFaviconImage(); + } + + /** + * Updates the best favicon if the given icon is better and the favicon is shown in + * notification. + */ + public void updateFavicon(Bitmap icon) { + if (icon == null) return; + + mMaybeHasFavicon = true; + + // Store the favicon only if notification is being shown. Otherwise the favicon is + // obtained from large icon bridge when needed. + if (isNotificationHidingOrHidden() || mPageMediaImage != null) return; + + // Disable favicons in notifications for low memory devices on O + // where the notification icon is optional. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && SysUtils.isLowEndDevice()) return; + + if (!MediaNotificationImageUtils.isBitmapSuitableAsMediaImage(icon)) return; + if (mFavicon != null + && (icon.getWidth() < mFavicon.getWidth() + || icon.getHeight() < mFavicon.getHeight())) { + return; + } + mFavicon = MediaNotificationImageUtils.downscaleIconToIdealSize(icon); + updateNotificationImage(mFavicon); + } + + /** Sets an icon which will preferentially be used in place of a smaller favicon. */ + public void setLargeIcon(Bitmap icon) { + if (isNotificationHidingOrHidden()) return; + + if (icon == null) { + // If we do not have any favicon then make sure we show default sound icon. This + // icon is used by notification manager only if we do not show any icon. + mNotificationInfoBuilder.setDefaultNotificationLargeIcon( + R.drawable.audio_playing_square); + showNotification(); + } else { + updateFavicon(icon); + } + } + + /** + * Updates the metadata in media notification. This method should be called whenever + * |mPageMetadata| or |mFallbackTitle| is changed. + */ + private void updateNotificationMetadata() { + if (isNotificationHidingOrHidden()) return; + + MediaMetadata newMetadata = getMetadata(); + if (mCurrentMetadata.equals(newMetadata)) return; + + mCurrentMetadata = newMetadata; + mNotificationInfoBuilder.setMetadata(mCurrentMetadata); + showNotification(); + } + + /** + * @return The up-to-date MediaSession metadata. Returns the cached object like |mPageMetadata| + * or |mCurrentMetadata| if it reflects the current state. Otherwise will return a new + * {@link MediaMetadata} object. + */ + private MediaMetadata getMetadata() { + String title = mFallbackTitle; + String artist = ""; + String album = ""; + if (mPageMetadata != null) { + if (!TextUtils.isEmpty(mPageMetadata.getTitle())) return mPageMetadata; + + artist = mPageMetadata.getArtist(); + album = mPageMetadata.getAlbum(); + } + + if (mCurrentMetadata != null && TextUtils.equals(title, mCurrentMetadata.getTitle()) + && TextUtils.equals(artist, mCurrentMetadata.getArtist()) + && TextUtils.equals(album, mCurrentMetadata.getAlbum())) { + return mCurrentMetadata; + } + + return new MediaMetadata(title, artist, album); + } + + private void updateNotificationActions() { + if (isNotificationHidingOrHidden()) return; + + mNotificationInfoBuilder.setMediaSessionActions(mMediaSessionActions); + showNotification(); + } + + private void updateNotificationPosition() { + if (isNotificationHidingOrHidden()) return; + + mNotificationInfoBuilder.setMediaPosition(mMediaPosition); + showNotification(); + } + + @Override + public void onImageDownloaded(Bitmap image) { + mPageMediaImage = MediaNotificationImageUtils.downscaleIconToIdealSize(image); + mFavicon = null; + updateNotificationImage(mPageMediaImage); + } + + private void updateNotificationImage(Bitmap newMediaImage) { + if (mCurrentMediaImage == newMediaImage) return; + + mCurrentMediaImage = newMediaImage; + + if (isNotificationHidingOrHidden()) return; + mNotificationInfoBuilder.setNotificationLargeIcon(mCurrentMediaImage); + mNotificationInfoBuilder.setMediaSessionImage(mPageMediaImage); + showNotification(); + } + + private Bitmap getCachedNotificationImage() { + if (mPageMediaImage != null) return mPageMediaImage; + if (mFavicon != null) return mFavicon; + return null; + } + + private boolean isNotificationHidingOrHidden() { + return mNotificationInfoBuilder == null; + } + + private MediaSession getMediaSession(WebContents contents) { + return (sOverriddenMediaSession != null) ? sOverriddenMediaSession + : MediaSession.fromWebContents(contents); + } +} diff --git a/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionUma.java b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionUma.java new file mode 100644 index 00000000000..58f489fc1e8 --- /dev/null +++ b/chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionUma.java @@ -0,0 +1,42 @@ +// 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.components.browser_ui.media; + +import androidx.annotation.IntDef; + +import org.chromium.base.metrics.RecordHistogram; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Centralizes UMA data collection for Android-specific MediaSession features. */ +public class MediaSessionUma { + // MediaSessionAction defined in tools/metrics/histograms/histograms.xml. + @IntDef({MediaSessionActionSource.MEDIA_NOTIFICATION, MediaSessionActionSource.MEDIA_SESSION, + MediaSessionActionSource.HEADSET_UNPLUG}) + @Retention(RetentionPolicy.SOURCE) + public @interface MediaSessionActionSource { + int MEDIA_NOTIFICATION = 0; + int MEDIA_SESSION = 1; + int HEADSET_UNPLUG = 2; + + int NUM_ENTRIES = 3; + } + + public static void recordPlay(@MediaSessionActionSource int action) { + RecordHistogram.recordEnumeratedHistogram( + "Media.Session.Play", action, MediaSessionActionSource.NUM_ENTRIES); + } + + public static void recordPause(@MediaSessionActionSource int action) { + RecordHistogram.recordEnumeratedHistogram( + "Media.Session.Pause", action, MediaSessionActionSource.NUM_ENTRIES); + } + + public static void recordStop(@MediaSessionActionSource int action) { + RecordHistogram.recordEnumeratedHistogram( + "Media.Session.Stop", action, MediaSessionActionSource.NUM_ENTRIES); + } +} |