summaryrefslogtreecommitdiff
path: root/chromium/components/browser_ui/media
diff options
context:
space:
mode:
authorAllan Sandfeld Jensen <allan.jensen@qt.io>2020-10-12 14:27:29 +0200
committerAllan Sandfeld Jensen <allan.jensen@qt.io>2020-10-13 09:35:20 +0000
commitc30a6232df03e1efbd9f3b226777b07e087a1122 (patch)
treee992f45784689f373bcc38d1b79a239ebe17ee23 /chromium/components/browser_ui/media
parent7b5b123ac58f58ffde0f4f6e488bcd09aa4decd3 (diff)
downloadqtwebengine-chromium-85-based.tar.gz
BASELINE: Update Chromium to 85.0.4183.14085-based
Change-Id: Iaa42f4680837c57725b1344f108c0196741f6057 Reviewed-by: Allan Sandfeld Jensen <allan.jensen@qt.io>
Diffstat (limited to 'chromium/components/browser_ui/media')
-rw-r--r--chromium/components/browser_ui/media/OWNERS4
-rw-r--r--chromium/components/browser_ui/media/android/BUILD.gn95
-rw-r--r--chromium/components/browser_ui/media/android/DEPS6
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing.pngbin0 -> 292 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing_square.pngbin0 -> 770 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_forward_white_36dp.pngbin0 -> 324 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_fast_rewind_white_36dp.pngbin0 -> 338 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_next_white_36dp.pngbin0 -> 248 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-hdpi/ic_skip_previous_white_36dp.pngbin0 -> 271 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing.pngbin0 -> 201 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing_square.pngbin0 -> 521 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_forward_white_36dp.pngbin0 -> 248 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_fast_rewind_white_36dp.pngbin0 -> 272 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_next_white_36dp.pngbin0 -> 183 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-mdpi/ic_skip_previous_white_36dp.pngbin0 -> 201 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing.pngbin0 -> 352 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing_square.pngbin0 -> 1056 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_forward_white_36dp.pngbin0 -> 326 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_fast_rewind_white_36dp.pngbin0 -> 375 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_next_white_36dp.pngbin0 -> 281 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/ic_skip_previous_white_36dp.pngbin0 -> 309 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing.pngbin0 -> 517 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing_square.pngbin0 -> 1851 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_forward_white_36dp.pngbin0 -> 545 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_fast_rewind_white_36dp.pngbin0 -> 546 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_next_white_36dp.pngbin0 -> 384 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/ic_skip_previous_white_36dp.pngbin0 -> 402 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing.pngbin0 -> 675 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing_square.pngbin0 -> 2695 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_forward_white_36dp.pngbin0 -> 625 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_fast_rewind_white_36dp.pngbin0 -> 644 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_next_white_36dp.pngbin0 -> 434 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/ic_skip_previous_white_36dp.pngbin0 -> 442 bytes
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageCallback.java22
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManager.java235
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaImageManagerTest.java298
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationButtonComputationTest.java96
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationController.java905
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationImageUtils.java74
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationInfo.java354
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationListener.java57
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaNotificationUma.java47
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionHelper.java573
-rw-r--r--chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionUma.java42
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
new file mode 100644
index 00000000000..75d13258a70
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing.png
Binary files differ
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
new file mode 100644
index 00000000000..94108427da0
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-hdpi/audio_playing_square.png
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new file mode 100644
index 00000000000..a9ccbc15101
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing.png
Binary files differ
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
new file mode 100644
index 00000000000..acbe6dbea23
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-mdpi/audio_playing_square.png
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new file mode 100644
index 00000000000..7fcb219818e
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing.png
Binary files differ
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
new file mode 100644
index 00000000000..212aa900ffb
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-xhdpi/audio_playing_square.png
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new file mode 100644
index 00000000000..a743b72ac6b
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing.png
Binary files differ
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
new file mode 100644
index 00000000000..403f6945051
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxhdpi/audio_playing_square.png
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new file mode 100644
index 00000000000..5139cbdba20
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing.png
Binary files differ
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
new file mode 100644
index 00000000000..e994ee339dd
--- /dev/null
+++ b/chromium/components/browser_ui/media/android/java/res/drawable-xxxhdpi/audio_playing_square.png
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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
new 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
Binary files differ
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);
+ }
+}