diff options
author | Allan Sandfeld Jensen <allan.jensen@qt.io> | 2018-05-03 13:42:47 +0200 |
---|---|---|
committer | Allan Sandfeld Jensen <allan.jensen@qt.io> | 2018-05-15 10:27:51 +0000 |
commit | 8c5c43c7b138c9b4b0bf56d946e61d3bbc111bec (patch) | |
tree | d29d987c4d7b173cf853279b79a51598f104b403 /chromium/chrome/browser/resources | |
parent | 830c9e163d31a9180fadca926b3e1d7dfffb5021 (diff) |
BASELINE: Update Chromium to 66.0.3359.156
Change-Id: I0c9831ad39911a086b6377b16f995ad75a51e441
Reviewed-by: Michal Klocek <michal.klocek@qt.io>
Diffstat (limited to 'chromium/chrome/browser/resources')
545 files changed, 30511 insertions, 3232 deletions
diff --git a/chromium/chrome/browser/resources/BUILD.gn b/chromium/chrome/browser/resources/BUILD.gn index f0559f28e62..c9f770b9a8e 100644 --- a/chromium/chrome/browser/resources/BUILD.gn +++ b/chromium/chrome/browser/resources/BUILD.gn @@ -166,15 +166,3 @@ if (enable_print_preview) { output_dir = "$root_gen_dir/chrome" } } - -if (enable_vr) { - grit("vr_shell_resources") { - source = "vr_shell_resources.grd" - defines = chrome_grit_defines - outputs = [ - "grit/vr_shell_resources.h", - "vr_shell_resources.pak", - ] - output_dir = "$root_gen_dir/chrome" - } -} diff --git a/chromium/chrome/browser/resources/PRESUBMIT.py b/chromium/chrome/browser/resources/PRESUBMIT.py index 9ebdb0ab8e6..e2a39c26688 100644 --- a/chromium/chrome/browser/resources/PRESUBMIT.py +++ b/chromium/chrome/browser/resources/PRESUBMIT.py @@ -8,8 +8,6 @@ See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts for more details about the presubmit API built into depot_tools. """ -import re - ACTION_XML_PATH = '../../../tools/metrics/actions/actions.xml' diff --git a/chromium/chrome/browser/resources/about_voicesearch.html b/chromium/chrome/browser/resources/about_voicesearch.html deleted file mode 100644 index 93a43dc6485..00000000000 --- a/chromium/chrome/browser/resources/about_voicesearch.html +++ /dev/null @@ -1,38 +0,0 @@ -<!doctype html> -<html i18n-values="dir:textdirection;lang:language"> -<head> -<meta charset="utf-8"> -<link rel="stylesheet" href="chrome://resources/css/text_defaults.css"> -<style> -.key { - font-weight: bold; -} - -.value { - margin-left: 15px; -} -</style> -</head> -<body> -<div id="loading-message" i18n-content="loadingMessage">LOADING_MESSAGE</div> -<div id="body-container" hidden> - <div id="header"> - <h1 i18n-content="voiceSearchLongTitle">ABOUT_VOICESEARCH</h1> - </div> - <div id="voice-search-info-template"> - <table cellpadding="2" cellspacing="0" border="0"> - <tr jsselect="voiceSearchInfo"> - <td><span dir="ltr" jscontent="key" class="key">KEY</span></td> - <td><span dir="ltr" jscontent="value" class="value">VALUE</span></td> - </tr> - </table> - </div> -</div> -<script src="chrome://resources/js/load_time_data.js"></script> -<script src="chrome://voicesearch/about_voicesearch.js"></script> -<script src="chrome://voicesearch/strings.js"></script> -<script src="chrome://resources/js/i18n_template.js"></script> -<script src="chrome://resources/js/jstemplate_compiled.js"></script> -<script src="chrome://resources/js/util.js"></script> -</body> -</html> diff --git a/chromium/chrome/browser/resources/about_voicesearch.js b/chromium/chrome/browser/resources/about_voicesearch.js deleted file mode 100644 index bcf8cbd5f7b..00000000000 --- a/chromium/chrome/browser/resources/about_voicesearch.js +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2014 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. - -/** - * Takes the |moduleListData| input argument which represents data about - * the currently available modules and populates the html jstemplate - * with that data. It expects an object structure like the above. - * @param {Object} moduleListData Information about available modules - */ -function renderTemplate(moduleListData) { - var input = new JsEvalContext(moduleListData); - var output = $('voice-search-info-template'); - jstProcess(input, output); -} - -/** - * Asks the C++ VoiceSearchUIDOMHandler to get details about voice search and - * return the data in returnVoiceSearchInfo() (below). - */ -function requestVoiceSearchInfo() { - chrome.send('requestVoiceSearchInfo'); -} - -/** - * Called by the WebUI to re-populate the page with data representing the - * current state of voice search. - * @param {Object} moduleListData Information about available modules. - */ -function returnVoiceSearchInfo(moduleListData) { - $('loading-message').hidden = true; - $('body-container').hidden = false; - renderTemplate(moduleListData); -} - -// Get data and have it displayed upon loading. -document.addEventListener('DOMContentLoaded', requestVoiceSearchInfo); diff --git a/chromium/chrome/browser/resources/app_list/OWNERS b/chromium/chrome/browser/resources/app_list/OWNERS deleted file mode 100644 index ed54f09c40c..00000000000 --- a/chromium/chrome/browser/resources/app_list/OWNERS +++ /dev/null @@ -1,2 +0,0 @@ -calamity@chromium.org -khmel@chromium.org diff --git a/chromium/chrome/browser/resources/app_list/start_page.css b/chromium/chrome/browser/resources/app_list/start_page.css deleted file mode 100644 index 3efaef1955a..00000000000 --- a/chromium/chrome/browser/resources/app_list/start_page.css +++ /dev/null @@ -1,32 +0,0 @@ -/* Copyright 2013 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. */ - -html, -body { - height: 100%; - margin: 0; - overflow: hidden; - padding: 0; - user-select: none; - width: 100%; -} - -#doodle { - display: none; - justify-content: center; -} - -#default_logo { - background-image: url(../../../../ui/webui/resources/images/google_logo.svg); - background-repeat: no-repeat; - height: 92px; - margin: auto; - width: 272px; -} - -#logo_container { - bottom: 0; - position: absolute; - width: 100%; -} diff --git a/chromium/chrome/browser/resources/app_list/start_page.html b/chromium/chrome/browser/resources/app_list/start_page.html deleted file mode 100644 index 5388e65d334..00000000000 --- a/chromium/chrome/browser/resources/app_list/start_page.html +++ /dev/null @@ -1,23 +0,0 @@ -<!doctype html> -<html i18n-values="dir:textdirection;lang:language"> -<head> - <meta charset="utf-8"> - <link rel="stylesheet" href="chrome://resources/css/text_defaults.css"> - <link rel="stylesheet" href="chrome://app-list/start_page.css"> - <script src="chrome://resources/js/load_time_data.js"></script> - <script src="chrome://resources/js/cr.js"></script> - <script src="chrome://resources/js/cr/event_target.js"></script> - <script src="chrome://resources/js/cr/ui.js"></script> - <script src="chrome://resources/js/util.js"></script> - <script src="chrome://app-list/strings.js"></script> - <script src="chrome://app-list/start_page.js"></script> - <base id="base"> -</head> - -<body> - <div id="logo_container"> - <div id="default_logo"></div> - </div> - <script src="chrome://resources/js/i18n_template.js"></script> -</body> -</html> diff --git a/chromium/chrome/browser/resources/app_list/start_page.js b/chromium/chrome/browser/resources/app_list/start_page.js deleted file mode 100644 index 2a01e136998..00000000000 --- a/chromium/chrome/browser/resources/app_list/start_page.js +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright 2013 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. - -/** - * @fileoverview App launcher start page implementation. - */ - -/** - * The maximum height of the Google Doodle. Note this value should be consistent - * with kWebViewHeight in start_page_view.cc. - */ -var doodleMaxHeight = 224; - -cr.define('appList.startPage', function() { - 'use strict'; - - // The element containing the current Google Doodle. - var doodle = null; - - /** - * Initialize the page. - */ - function initialize() { - chrome.send('initialize'); - } - - /** - * Invoked when the app-list bubble is shown. - */ - function onAppListShown() { - chrome.send('appListShown', [this.doodle != null]); - } - - /** - * Sets the doodle's visibility, hiding or showing the default logo. - * - * @param {boolean} visible Whether the doodle should be made visible. - */ - function setDoodleVisible(visible) { - var doodle = $('doodle'); - var defaultLogo = $('default_logo'); - if (visible) { - doodle.style.display = 'flex'; - defaultLogo.style.display = 'none'; - } else { - if (doodle) - doodle.style.display = 'none'; - - defaultLogo.style.display = 'block'; - } - } - - /** - * Invoked when the app-list doodle is updated. - * - * @param {Object} data The data object representing the current doodle. - */ - function onAppListDoodleUpdated(data, base_url) { - if (this.doodle) { - this.doodle.parentNode.removeChild(this.doodle); - this.doodle = null; - } - - var doodleData = data.ddljson; - if (!doodleData || !doodleData.transparent_large_image) { - setDoodleVisible(false); - return; - } - - // Set the page's base URL so that links will resolve relative to the Google - // homepage. - $('base').href = base_url; - - this.doodle = document.createElement('div'); - this.doodle.id = 'doodle'; - this.doodle.style.display = 'none'; - - var doodleImage = document.createElement('img'); - doodleImage.id = 'doodle_image'; - if (doodleData.transparent_large_image.height > doodleMaxHeight) - doodleImage.setAttribute('height', doodleMaxHeight); - if (doodleData.alt_text) { - doodleImage.alt = doodleData.alt_text; - doodleImage.title = doodleData.alt_text; - } - - doodleImage.onload = function() { - setDoodleVisible(true); - }; - doodleImage.src = doodleData.transparent_large_image.url; - - if (doodleData.target_url) { - var doodleLink = document.createElement('a'); - doodleLink.id = 'doodle_link'; - doodleLink.href = doodleData.target_url; - doodleLink.target = '_blank'; - doodleLink.appendChild(doodleImage); - doodleLink.onclick = function() { - chrome.send('doodleClicked'); - return true; - }; - this.doodle.appendChild(doodleLink); - } else { - this.doodle.appendChild(doodleImage); - } - $('logo_container').appendChild(this.doodle); - } - - return { - initialize: initialize, - onAppListDoodleUpdated: onAppListDoodleUpdated, - onAppListShown: onAppListShown, - }; -}); - -document.addEventListener('contextmenu', function(e) { - e.preventDefault(); -}); -document.addEventListener('DOMContentLoaded', appList.startPage.initialize); diff --git a/chromium/chrome/browser/resources/bluetooth_internals/adapter_broker.js b/chromium/chrome/browser/resources/bluetooth_internals/adapter_broker.js index e357336fe7e..d345167a581 100644 --- a/chromium/chrome/browser/resources/bluetooth_internals/adapter_broker.js +++ b/chromium/chrome/browser/resources/bluetooth_internals/adapter_broker.js @@ -111,7 +111,7 @@ cr.define('adapter_broker', function() { /** * The implementation of AdapterClient in - * device/bluetooth/public/interfaces/adapter.mojom. Dispatches events + * device/bluetooth/public/mojom/adapter.mojom. Dispatches events * through AdapterBroker to notify client objects of changes to the Adapter * service. * @constructor diff --git a/chromium/chrome/browser/resources/chromeos/chromevox/BUILD.gn b/chromium/chrome/browser/resources/chromeos/chromevox/BUILD.gn index 98fdb591fca..329a2f37113 100644 --- a/chromium/chrome/browser/resources/chromeos/chromevox/BUILD.gn +++ b/chromium/chrome/browser/resources/chromeos/chromevox/BUILD.gn @@ -126,6 +126,7 @@ chromevox_modules = [ "cvox2/background/automation_util.js", "cvox2/background/background.js", "cvox2/background/base_automation_handler.js", + "cvox2/background/braille_command_data.js", "cvox2/background/braille_command_handler.js", "cvox2/background/chromevox_state.js", "cvox2/background/command_handler.js", diff --git a/chromium/chrome/browser/resources/chromeos/chromevox/strings/chromevox_strings.grd b/chromium/chrome/browser/resources/chromeos/chromevox/strings/chromevox_strings.grd index ed7b45e0e20..f47bdfdc16b 100644 --- a/chromium/chrome/browser/resources/chromeos/chromevox/strings/chromevox_strings.grd +++ b/chromium/chrome/browser/resources/chromeos/chromevox/strings/chromevox_strings.grd @@ -2783,6 +2783,27 @@ If you're done with the tutorial, use ChromeVox to navigate to the Close button <message desc="Shown to a user when they invoke the read current title command in a context without a title." name="IDS_CHROMEVOX_NO_TITLE"> No title </message> + <message desc="Spoken when a user issues a command when nothing is focused." name="IDS_CHROMEVOX_WARNING_NO_CURRENT_RANGE"> + No focus. Press Ctrl+T to open a new tab. + </message> + <message desc="A hint to the user that the current control is checkable." name="IDS_CHROMEVOX_HINT_CHECKABLE"> + Press Search+Space to toggle. + </message> + <message desc="A hint to the user that the current control is clickable." name="IDS_CHROMEVOX_HINT_CLICKABLE"> + Press Search+Space to activate. + </message> + <message desc="A hint to the user that the current control has a list of auto completions." name="IDS_CHROMEVOX_HINT_AUTOCOMPLETE_LIST"> + Press up or down arrow for auto completions. + </message> + <message desc="A hint to the user that the current control has inline auto completions." name="IDS_CHROMEVOX_HINT_AUTOCOMPLETE_INLINE"> + Type to auto complete. + </message> + <message desc="A hint to the user for interacting with the table control." name="IDS_CHROMEVOX_HINT_TABLE"> + Press Search+Ctrl+Alt with arrows to navigate by cell. + </message> + <message desc="A hint to the user for interacting with the menu control." name="IDS_CHROMEVOX_HINT_MENU"> + Press up or down arrow to navigate; enter to activate. + </message> </messages> </release> </grit> diff --git a/chromium/chrome/browser/resources/chromeos/genius_app/manifest.json b/chromium/chrome/browser/resources/chromeos/genius_app/manifest.json index 6a625f766a7..e0f1b720490 100644 --- a/chromium/chrome/browser/resources/chromeos/genius_app/manifest.json +++ b/chromium/chrome/browser/resources/chromeos/genius_app/manifest.json @@ -46,6 +46,7 @@ "https://www.googleapis.com/auth/supportcontent", "https://www.googleapis.com/auth/cases", "https://www.googleapis.com/auth/cases.readonly", + "https://www.googleapis.com/auth/pixelbook.email.preferences", "https://www.google.com/accounts/OAuthLogin" ] }, diff --git a/chromium/chrome/browser/resources/chromeos/login/BUILD.gn b/chromium/chrome/browser/resources/chromeos/login/BUILD.gn deleted file mode 100644 index 8ad38cb8443..00000000000 --- a/chromium/chrome/browser/resources/chromeos/login/BUILD.gn +++ /dev/null @@ -1,30 +0,0 @@ -# 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. - -import("//third_party/closure_compiler/compile_js.gni") - -group("closure_compile") { - deps = [ - ":offline_ad_login", - ":oobe_change_picture", - ] -} - -js_binary("offline_ad_login") { - deps = [ - "//ui/webui/resources/js:load_time_data", - ] -} - -js_binary("oobe_change_picture") { - deps = [ - "//ui/webui/resources/cr_elements/chromeos/cr_picture:cr_picture_list", - "//ui/webui/resources/cr_elements/chromeos/cr_picture:cr_picture_pane", - "//ui/webui/resources/cr_elements/chromeos/cr_picture:cr_picture_types", - "//ui/webui/resources/js:assert", - "//ui/webui/resources/js:i18n_behavior", - "//ui/webui/resources/js:load_time_data", - "//ui/webui/resources/js:util", - ] -} diff --git a/chromium/chrome/browser/resources/chromeos/login/compiled_resources2.gyp b/chromium/chrome/browser/resources/chromeos/login/compiled_resources2.gyp index de8e38ce6b7..dbe606c4978 100644 --- a/chromium/chrome/browser/resources/chromeos/login/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/chromeos/login/compiled_resources2.gyp @@ -4,9 +4,14 @@ { 'targets': [ { + 'target_name': 'oobe_select', + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { 'target_name': 'offline_ad_login', 'dependencies': [ '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:load_time_data', + ':oobe_select', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, diff --git a/chromium/chrome/browser/resources/chromeos/quick_unlock/compiled_resources2.gyp b/chromium/chrome/browser/resources/chromeos/quick_unlock/compiled_resources2.gyp deleted file mode 100644 index a953cb9433b..00000000000 --- a/chromium/chrome/browser/resources/chromeos/quick_unlock/compiled_resources2.gyp +++ /dev/null @@ -1,25 +0,0 @@ -# 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. -{ - 'targets': [ - { - 'target_name': 'md_pin_keyboard', - 'dependencies': [ - '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', - '<(DEPTH)/third_party/polymer/v1_0/components-chromium/paper-button/compiled_resources2.gyp:paper-button-extracted', - '<(DEPTH)/third_party/polymer/v1_0/components-chromium/paper-input/compiled_resources2.gyp:paper-input-extracted', - ], - 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], - }, - { - 'target_name': 'pin_keyboard', - 'dependencies': [ - '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', - '<(DEPTH)/third_party/polymer/v1_0/components-chromium/paper-button/compiled_resources2.gyp:paper-button-extracted', - '<(DEPTH)/third_party/polymer/v1_0/components-chromium/paper-input/compiled_resources2.gyp:paper-input-extracted', - ], - 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], - }, - ], -} diff --git a/chromium/chrome/browser/resources/chromeos/select_to_speak/BUILD.gn b/chromium/chrome/browser/resources/chromeos/select_to_speak/BUILD.gn index 3a2962e1cab..d01029bf728 100644 --- a/chromium/chrome/browser/resources/chromeos/select_to_speak/BUILD.gn +++ b/chromium/chrome/browser/resources/chromeos/select_to_speak/BUILD.gn @@ -32,14 +32,18 @@ run_jsbundler("select_to_speak_copied_files") { "../chromevox/cvox2/background/tree_walker.js", "checked.png", "closure_shim.js", + "earcons/null_selection.ogg", + "node_utils.js", "options.css", "options.html", "paragraph_utils.js", + "rect_utils.js", "select_to_speak.js", "select_to_speak_gdocs_script.js", "select_to_speak_main.js", "select_to_speak_options.js", "unchecked.png", + "word_utils.js", ] rewrite_rules = [ rebase_path(".", root_build_dir) + ":", @@ -146,11 +150,13 @@ js2gtest("select_to_speak_extjs_tests") { test_type = "extension" sources = [ "select_to_speak_keystroke_selection_test.extjs", + "select_to_speak_mouse_selection_test.extjs", ] gen_include_files = [ "../chromevox/testing/callback_helper.js", "mock_tts.js", "select_to_speak_e2e_test_base.js", + "pipe.jpg", ] defines = [ "HAS_OUT_OF_PROC_TEST_RUNNER" ] } diff --git a/chromium/chrome/browser/resources/chromeos/select_to_speak/compiled_resources2.gyp b/chromium/chrome/browser/resources/chromeos/select_to_speak/compiled_resources2.gyp index ec7035886ae..b7a05836e6f 100644 --- a/chromium/chrome/browser/resources/chromeos/select_to_speak/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/chromeos/select_to_speak/compiled_resources2.gyp @@ -8,24 +8,28 @@ 'dependencies': [ '../chromevox/cvox2/background/constants', '../chromevox/cvox2/background/automation_util', - 'externs', - 'paragraph_utils', - '<(EXTERNS_GYP):accessibility_private', - '<(EXTERNS_GYP):automation', - '<(EXTERNS_GYP):chrome_extensions', - '<(EXTERNS_GYP):command_line_private', - '<(EXTERNS_GYP):metrics_private', + 'externs', + 'rect_utils', + 'paragraph_utils', + 'word_utils', + 'node_utils', + '<(EXTERNS_GYP):accessibility_private', + '<(EXTERNS_GYP):automation', + '<(EXTERNS_GYP):chrome_extensions', + '<(EXTERNS_GYP):clipboard', + '<(EXTERNS_GYP):command_line_private', + '<(EXTERNS_GYP):metrics_private', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, { 'target_name': 'select_to_speak_options', 'dependencies': [ - 'externs', - '<(EXTERNS_GYP):accessibility_private', - '<(EXTERNS_GYP):automation', - '<(EXTERNS_GYP):chrome_extensions', - '<(EXTERNS_GYP):metrics_private', + 'externs', + '<(EXTERNS_GYP):accessibility_private', + '<(EXTERNS_GYP):automation', + '<(EXTERNS_GYP):chrome_extensions', + '<(EXTERNS_GYP):metrics_private', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, @@ -34,42 +38,65 @@ 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, { + 'target_name': 'node_utils', + 'dependencies': [ + 'externs', + 'rect_utils', + 'paragraph_utils', + '<(EXTERNS_GYP):automation', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'word_utils', + 'dependencies': [ + 'externs', + 'paragraph_utils', + '<(EXTERNS_GYP):automation', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { 'target_name': 'paragraph_utils', 'dependencies': [ - 'externs', - '<(EXTERNS_GYP):accessibility_private', - '<(EXTERNS_GYP):automation', - '<(EXTERNS_GYP):chrome_extensions', + 'externs', + '<(EXTERNS_GYP):accessibility_private', + '<(EXTERNS_GYP):automation', + '<(EXTERNS_GYP):chrome_extensions', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, { + 'target_name': 'rect_utils', + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { 'target_name': '../chromevox/cvox2/background/automation_util', 'dependencies': [ - '../chromevox/cvox2/background/automation_predicate', - '../chromevox/cvox2/background/tree_walker', - '../chromevox/cvox2/background/constants', - '<(EXTERNS_GYP):automation', - '<(EXTERNS_GYP):chrome_extensions', + '../chromevox/cvox2/background/automation_predicate', + '../chromevox/cvox2/background/tree_walker', + '../chromevox/cvox2/background/constants', + '<(EXTERNS_GYP):automation', + '<(EXTERNS_GYP):chrome_extensions', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, { 'target_name': '../chromevox/cvox2/background/tree_walker', 'dependencies': [ - '../chromevox/cvox2/background/automation_predicate', - '../chromevox/cvox2/background/constants', - '<(EXTERNS_GYP):automation', - '<(EXTERNS_GYP):chrome_extensions', + '../chromevox/cvox2/background/automation_predicate', + '../chromevox/cvox2/background/constants', + '<(EXTERNS_GYP):automation', + '<(EXTERNS_GYP):chrome_extensions', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, { 'target_name': '../chromevox/cvox2/background/automation_predicate', 'dependencies': [ - '../chromevox/cvox2/background/constants', - '<(EXTERNS_GYP):automation', - '<(EXTERNS_GYP):chrome_extensions', + '../chromevox/cvox2/background/constants', + '<(EXTERNS_GYP):automation', + '<(EXTERNS_GYP):chrome_extensions', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, diff --git a/chromium/chrome/browser/resources/chromeos/wallpaper_manager/manifest.json b/chromium/chrome/browser/resources/chromeos/wallpaper_manager/manifest.json index 8feeb9309b5..d4c6c37d202 100644 --- a/chromium/chrome/browser/resources/chromeos/wallpaper_manager/manifest.json +++ b/chromium/chrome/browser/resources/chromeos/wallpaper_manager/manifest.json @@ -20,6 +20,7 @@ "alarms", "app.window.alpha", "chrome://resources/", + "commandLinePrivate", "experimental", "storage", "unlimitedStorage", diff --git a/chromium/chrome/browser/resources/components.js b/chromium/chrome/browser/resources/components.js index f1247bbc0b3..8d29201a3d0 100644 --- a/chromium/chrome/browser/resources/components.js +++ b/chromium/chrome/browser/resources/components.js @@ -5,6 +5,13 @@ 'use strict'; /** + * An array of the latest component data including ID, name, status and + * version. This is populated in returnComponentsData() for the convenience of + * tests. + */ +var currentComponentsData = null; + +/** * Takes the |componentsData| input argument which represents data about the * currently installed components and populates the html jstemplate with * that data. It expects an object structure like the above. @@ -32,7 +39,8 @@ function requestComponentsData() { /** * Called by the WebUI to re-populate the page with data representing the - * current state of installed components. + * current state of installed components. The componentsData will also be + * stored in currentComponentsData to be available to JS for testing purposes. * @param {Object} componentsData Detailed info about installed components. The * template expects each component's format to match the following * structure to correctly populate the page: @@ -56,6 +64,10 @@ function returnComponentsData(componentsData) { bodyContainer.style.visibility = 'hidden'; body.className = ''; + // Initialize |currentComponentsData|, which can also be updated in + // onComponentEvent() later. + currentComponentsData = componentsData.components; + renderTemplate(componentsData); // Add handlers to dynamically created HTML elements. @@ -85,12 +97,24 @@ function returnComponentsData(componentsData) { * optional. */ function onComponentEvent(eventArgs) { - if (eventArgs['id']) { - var id = eventArgs['id']; - $('status-' + id).textContent = eventArgs['event']; - } + if (!eventArgs['id']) + return; + + var id = eventArgs['id']; + + var filteredComponents = currentComponentsData.filter(function(entry) { + return entry.id === id; + }); + var component = filteredComponents[0]; + + var status = eventArgs['event']; + $('status-' + id).textContent = status; + component['status'] = status; + if (eventArgs['version']) { - $('version-' + id).textContent = eventArgs['version']; + var version = eventArgs['version']; + $('version-' + id).textContent = version; + component['version'] = version; } } diff --git a/chromium/chrome/browser/resources/cryptotoken/enroller.js b/chromium/chrome/browser/resources/cryptotoken/enroller.js index 9b1349281cd..4bc7bb02d16 100644 --- a/chromium/chrome/browser/resources/cryptotoken/enroller.js +++ b/chromium/chrome/browser/resources/cryptotoken/enroller.js @@ -144,7 +144,7 @@ async function makeCertAndKey(original) { b.addASN1(Tag.SET, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1ObjectIdentifier(commonName); - b.addASN1PrintableString('U2F'); + b.addASN1PrintableString('U2F Issuer'); }); }); }); @@ -160,7 +160,7 @@ async function makeCertAndKey(original) { b.addASN1(Tag.SET, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1ObjectIdentifier(commonName); - b.addASN1PrintableString('U2F'); + b.addASN1PrintableString('U2F Device'); }); }); }); @@ -192,7 +192,21 @@ async function makeCertAndKey(original) { b.addASN1ObjectIdentifier(ecdsaWithSHA256); }); b.addASN1(Tag.BITSTRING, (b) => { // Signature - b.addBytesFromString('\x00'); // (not valid, obviously.) + // This signature is obviously not correct since it's constant and the + // rest of the certificate is not. However, since the issuer certificate + // doesn't exist, there's no way for anyone to check the signature on this + // certificate and thus this sufficies. However, at least fastmail.com + // expects to be able to parse out a valid ECDSA signature and so one is + // provided. + b.addBytes(new Uint8Array([ + 0x00, 0x30, 0x45, 0x02, 0x21, 0x00, 0xc1, 0xa3, 0xa6, 0x8e, 0x2f, + 0x16, 0xa7, 0x21, 0x46, 0x27, 0x05, 0x7f, 0x62, 0xbb, 0x72, 0x8c, + 0x9e, 0x03, 0xe7, 0xa1, 0xba, 0x62, 0xd0, 0x46, 0x52, 0x4e, 0x45, + 0x6d, 0x2c, 0x2f, 0x3f, 0x73, 0x02, 0x20, 0x0b, 0x5f, 0x78, 0xe5, + 0x11, 0xaa, 0x18, 0x12, 0x9f, 0x6f, 0x23, 0x6d, 0x92, 0x13, 0x22, + 0x7d, 0x92, 0xb4, 0xe6, 0x7e, 0xdf, 0x53, 0xe8, 0x16, 0xdf, 0xb0, + 0x5d, 0x9d, 0xc8, 0xb9, 0x0f, 0xde + ])); }); }); return {privateKey: keypair.privateKey, certDER: certBuilder.data}; @@ -207,11 +221,13 @@ const Registration = class { * @param {string} registrationData the registration response message, * base64-encoded. * @param {string} appId the application identifier. - * @param {string=} opt_clientData the client data, base64-encoded. This - * field is not really optional; it is an error if it is empty or missing. + * @param {string} challenge the server-generated challenge parameter. This + * is only used if opt_clientData is null and, in that case, is expected + * to be a webSafeBase64-encoded, 32-byte value. + * @param {string=} opt_clientData the client data, base64-encoded. * @throws {Error} */ - constructor(registrationData, appId, opt_clientData) { + constructor(registrationData, appId, challenge, opt_clientData) { var data = new ByteString(decodeWebSafeBase64ToArray(registrationData)); var magic = data.getBytes(1); if (magic[0] != 5) { @@ -231,12 +247,21 @@ const Registration = class { throw Error('extra trailing bytes'); } + var challengeHash; if (!opt_clientData) { - throw Error('missing client data'); + // U2F_V1 - deprecated + challengeHash = decodeWebSafeBase64ToArray(challenge); + if (challengeHash.length != 32) { + throw Error('bad challenge length for U2F_V1'); + } + } else { + // U2F_V2 + challengeHash = + sha256HashOfString(atob(webSafeBase64ToNormal(opt_clientData))); } + /** @private {string} */ - this.clientData_ = atob(webSafeBase64ToNormal(opt_clientData)); - JSON.parse(this.clientData_); // Just checking. + this.challengeHash_ = challengeHash; /** @private {string} */ this.appId_ = appId; @@ -262,7 +287,7 @@ const Registration = class { var tbs = new ByteBuilder(); tbs.addBytesFromString('\0'); tbs.addBytes(sha256HashOfString(this.appId_)); - tbs.addBytes(sha256HashOfString(this.clientData_)); + tbs.addBytes(this.challengeHash_); tbs.addBytes(this.keyHandle_); tbs.addBytes(this.publicKey_); return tbs.data; @@ -338,10 +363,11 @@ var ConveyancePreference = { */ function conveyancePreference(enrollChallenge) { if (enrollChallenge.hasOwnProperty('attestation') && - enrollChallenge['attestation'] == 'none') { - return ConveyancePreference.NONE; + (enrollChallenge['attestation'] == 'direct' || + enrollChallenge['attestation'] == 'indirect')) { + return ConveyancePreference.DIRECT; } - return ConveyancePreference.DIRECT; + return ConveyancePreference.NONE; } /** @@ -379,7 +405,8 @@ function handleU2fEnrollRequest(messageSender, request, sendResponse) { return registrationData; } - const reg = new Registration(registrationData, appId, opt_clientData); + const reg = new Registration( + registrationData, appId, enrollChallenge['challenge'], opt_clientData); const keypair = await makeCertAndKey(reg.certificate); const signature = await reg.sign(keypair.privateKey); return reg.withReplacement(keypair.certDER, signature); @@ -538,7 +565,7 @@ function isValidEnrollChallengeArray(enrollChallenges, appIdRequired) { } /** - * Finds the enroll challenge of the given version in the enroll challlenge + * Finds the enroll challenge of the given version in the enroll challenge * array. * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges to * search. diff --git a/chromium/chrome/browser/resources/cryptotoken/gnubby-u2f.js b/chromium/chrome/browser/resources/cryptotoken/gnubby-u2f.js index d0335ec9ddf..4d219debe02 100644 --- a/chromium/chrome/browser/resources/cryptotoken/gnubby-u2f.js +++ b/chromium/chrome/browser/resources/cryptotoken/gnubby-u2f.js @@ -151,8 +151,8 @@ Gnubby.prototype.version = function(cb) { cb(-GnubbyDevice.OK, v1.buffer); return; } - if (rc == 0x6700) { - // Wrong length. Try with non-ISO 7816-4-conforming layout defined in + if (rc) { + // Error. Try with non-ISO 7816-4-conforming layout defined in // earlier U2F drafts. apdu = new Uint8Array( [0x00, Gnubby.U2F_VERSION, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); diff --git a/chromium/chrome/browser/resources/cryptotoken/manifest.json b/chromium/chrome/browser/resources/cryptotoken/manifest.json index fecac5426a2..2f86ab26a09 100644 --- a/chromium/chrome/browser/resources/cryptotoken/manifest.json +++ b/chromium/chrome/browser/resources/cryptotoken/manifest.json @@ -24,7 +24,7 @@ ], "externally_connectable": { "matches": [ - "<all_urls>" + "https://*/*" ], "ids": [ "fjajfjhkeibgmiggdfehjplbhmfkialk" diff --git a/chromium/chrome/browser/resources/download_internals/download_internals.html b/chromium/chrome/browser/resources/download_internals/download_internals.html index 49e67cdce47..acb85302d75 100644 --- a/chromium/chrome/browser/resources/download_internals/download_internals.html +++ b/chromium/chrome/browser/resources/download_internals/download_internals.html @@ -21,6 +21,12 @@ </head> <body> <h1>Download Internals</h1> + <h4>Start Download</h4> + <div id="download-service-request-info"> + <input id="download-url" type="url" + placeholder="http://www.example.com"> + <button id="start-download">Download</button> + </div> <h4>Service State</h4> <div> State: <span id="service-state" class="status"></span> diff --git a/chromium/chrome/browser/resources/download_internals/download_internals.js b/chromium/chrome/browser/resources/download_internals/download_internals.js index 62f1f1f0ab8..d46a2106ca7 100644 --- a/chromium/chrome/browser/resources/download_internals/download_internals.js +++ b/chromium/chrome/browser/resources/download_internals/download_internals.js @@ -122,6 +122,10 @@ cr.define('downloadInternals', function() { cr.addWebUIListener('service-download-failed', onServiceDownloadFailed); cr.addWebUIListener('service-request-made', onServiceRequestMade); + $('start-download').onclick = function() { + browserProxy.startDownload($('download-url').value); + }; + // Kick off requests for the current system state. browserProxy.getServiceStatus().then(onServiceStatusChanged); browserProxy.getServiceDownloads().then(onServiceDownloadsAvailable); diff --git a/chromium/chrome/browser/resources/download_internals/download_internals_browser_proxy.js b/chromium/chrome/browser/resources/download_internals/download_internals_browser_proxy.js index 89642e117d5..a79453ac7f3 100644 --- a/chromium/chrome/browser/resources/download_internals/download_internals_browser_proxy.js +++ b/chromium/chrome/browser/resources/download_internals/download_internals_browser_proxy.js @@ -105,6 +105,12 @@ cr.define('downloadInternals', function() { * of downloads is fetched. */ getServiceDownloads() {} + + /** + * Starts a download with the Download Service. + * @param {string} url The download URL. + */ + startDownload(url) {} } /** @@ -120,6 +126,11 @@ cr.define('downloadInternals', function() { getServiceDownloads() { return cr.sendWithPromise('getServiceDownloads'); } + + /** @override */ + startDownload(url) { + return cr.sendWithPromise('startDownload', url); + } } cr.addSingletonGetter(DownloadInternalsBrowserProxyImpl); diff --git a/chromium/chrome/browser/resources/extensions/extensions.js b/chromium/chrome/browser/resources/extensions/extensions.js index 663f8382b3d..4f9d1b2beb6 100644 --- a/chromium/chrome/browser/resources/extensions/extensions.js +++ b/chromium/chrome/browser/resources/extensions/extensions.js @@ -19,10 +19,6 @@ // <include src="chromeos/kiosk_apps.js"> // </if> -// Used for observing function of the backend datasource for this page by -// tests. -var webuiResponded = false; - cr.define('extensions', function() { var ExtensionList = extensions.ExtensionList; @@ -185,7 +181,6 @@ cr.define('extensions', function() { // don't need to display the interstitial spinner. if (!this.hasLoaded_) this.setLoading_(true); - webuiResponded = true; /** @const */ var supervised = profileInfo.isSupervised; diff --git a/chromium/chrome/browser/resources/feedback/js/feedback.js b/chromium/chrome/browser/resources/feedback/js/feedback.js index 909a5e50a68..538d7c825e4 100644 --- a/chromium/chrome/browser/resources/feedback/js/feedback.js +++ b/chromium/chrome/browser/resources/feedback/js/feedback.js @@ -334,6 +334,13 @@ function initialize() { }); chrome.app.window.current().show(); + // Allow feedback to be sent even if the screenshot failed. + if (!screenshotCanvas) { + $('screenshot-checkbox').disabled = true; + $('screenshot-checkbox').checked = false; + return; + } + screenshotCanvas.toBlob(function(blob) { $('screenshot-image').src = URL.createObjectURL(blob); // Only set the alt text when the src url is available, otherwise we'd diff --git a/chromium/chrome/browser/resources/feedback/js/take_screenshot.js b/chromium/chrome/browser/resources/feedback/js/take_screenshot.js index 3f099724440..830fd726004 100644 --- a/chromium/chrome/browser/resources/feedback/js/take_screenshot.js +++ b/chromium/chrome/browser/resources/feedback/js/take_screenshot.js @@ -5,7 +5,7 @@ /** * Function to take the screenshot of the current screen. * @param {function(HTMLCanvasElement)} callback Callback for returning the - * canvas with the screenshot on it. + * canvas with the screenshot. Called with null if the screenshot failed. */ function takeScreenshot(callback) { var screenshotStream = null; @@ -47,5 +47,6 @@ function takeScreenshot(callback) { console.error( 'takeScreenshot failed: ' + err.name + '; ' + err.message + '; ' + err.constraintName); + callback(null); }); } diff --git a/chromium/chrome/browser/resources/gaia_auth_host/saml_handler.js b/chromium/chrome/browser/resources/gaia_auth_host/saml_handler.js index 670bf7f90af..ba62489f2e2 100644 --- a/chromium/chrome/browser/resources/gaia_auth_host/saml_handler.js +++ b/chromium/chrome/browser/resources/gaia_auth_host/saml_handler.js @@ -469,7 +469,7 @@ cr.define('cr.login', function() { this.authDomain = extractDomain(msg.url); this.dispatchEvent(new CustomEvent('authPageLoaded', { detail: { - url: url, + url: msg.url, isSAMLPage: this.isSamlPage_, domain: this.authDomain } diff --git a/chromium/chrome/browser/resources/identity_internals.html b/chromium/chrome/browser/resources/identity_internals.html index fcfcc431028..3be44786a73 100644 --- a/chromium/chrome/browser/resources/identity_internals.html +++ b/chromium/chrome/browser/resources/identity_internals.html @@ -1,8 +1,8 @@ <!doctype html> -<html i18n-values="dir:textdirection;lang:language"> +<html dir="$i18n{textdirection}" lang="$i18n{language}"> <head> <meta charset="utf-8"> - <title i18n-content="tokenCacheHeader"></title> + <title>$i18n{tokenCacheHeader}</title> <link rel="stylesheet" href="chrome://resources/css/text_defaults.css"> <link rel="stylesheet" href="identity_internals.css"> <script src="chrome://resources/js/cr.js"></script> @@ -13,7 +13,7 @@ <script src="identity_internals.js"></script> </head> <body> - <h2 class="header" i18n-content="tokenCacheHeader"></h2> + <h2 class="header">$i18n{tokenCacheHeader}</h2> <div id="token-list"></div> <script src="chrome://resources/js/i18n_template.js"></script> </body> diff --git a/chromium/chrome/browser/resources/input_ime/ime_window_close.png b/chromium/chrome/browser/resources/input_ime/ime_window_close.png Binary files differindex 0010118ca99..6b3bd5d836f 100644 --- a/chromium/chrome/browser/resources/input_ime/ime_window_close.png +++ b/chromium/chrome/browser/resources/input_ime/ime_window_close.png diff --git a/chromium/chrome/browser/resources/input_ime/ime_window_close_click.png b/chromium/chrome/browser/resources/input_ime/ime_window_close_click.png Binary files differindex a954503b6d3..60a218cf395 100644 --- a/chromium/chrome/browser/resources/input_ime/ime_window_close_click.png +++ b/chromium/chrome/browser/resources/input_ime/ime_window_close_click.png diff --git a/chromium/chrome/browser/resources/input_ime/ime_window_close_hover.png b/chromium/chrome/browser/resources/input_ime/ime_window_close_hover.png Binary files differindex a954503b6d3..60a218cf395 100644 --- a/chromium/chrome/browser/resources/input_ime/ime_window_close_hover.png +++ b/chromium/chrome/browser/resources/input_ime/ime_window_close_hover.png diff --git a/chromium/chrome/browser/resources/inspect/inspect.css b/chromium/chrome/browser/resources/inspect/inspect.css index 5bd5a239edb..eefce85827b 100644 --- a/chromium/chrome/browser/resources/inspect/inspect.css +++ b/chromium/chrome/browser/resources/inspect/inspect.css @@ -178,6 +178,14 @@ img { margin-left: 6px; } +.browser-fallback-note { + display: flex; + flex-flow: row wrap; + margin-left: 4px; + margin-top: 5px; + min-height: 15px; +} + .used-for-port-forwarding { background-image: url(../../../../ui/webui/resources/images/info.svg); height: 15px; diff --git a/chromium/chrome/browser/resources/inspect/inspect.js b/chromium/chrome/browser/resources/inspect/inspect.js index 64288b83e80..1901d601dd6 100644 --- a/chromium/chrome/browser/resources/inspect/inspect.js +++ b/chromium/chrome/browser/resources/inspect/inspect.js @@ -7,12 +7,17 @@ var MIN_VERSION_TARGET_ID = 26; var MIN_VERSION_NEW_TAB = 29; var MIN_VERSION_TAB_ACTIVATE = 30; var WEBRTC_SERIAL = 'WEBRTC'; +var HOST_CHROME_VERSION; var queryParamsObject = {}; var browserInspector; var browserInspectorTitle; (function() { +var chromeMatch = navigator.userAgent.match(/(?:^|\W)Chrome\/(\S+)/); +if (chromeMatch && chromeMatch.length > 1) + HOST_CHROME_VERSION = chromeMatch[1].split('.').map(s => Number(s) || 0); + var queryParams = window.location.search; if (!queryParams) return; @@ -31,6 +36,21 @@ if ('trace' in queryParamsObject || 'tracing' in queryParamsObject) { } })(); +function isVersionNewerThanHost(version) { + if (!HOST_CHROME_VERSION) + return false; + version = version.split('.').map(s => Number(s) || 0); + for (var i = 0; i < HOST_CHROME_VERSION.length; i++) { + if (i > version.length) + return false; + if (HOST_CHROME_VERSION[i] > version[i]) + return false; + if (HOST_CHROME_VERSION[i] < version[i]) + return true; + } + return false; +} + function sendCommand(command, args) { chrome.send(command, Array.prototype.slice.call(arguments, 1)); } @@ -295,6 +315,8 @@ function populateRemoteTargets(devices) { var majorChromeVersion = browser.adbBrowserChromeVersion; var pageList; var browserSection = $(browser.id); + var browserNeedsFallback = + isVersionNewerThanHost(browser.adbBrowserVersion); if (browserSection) { pageList = browserSection.querySelector('.pages'); } else { @@ -320,6 +342,15 @@ function populateRemoteTargets(devices) { } browserSection.appendChild(browserHeader); + if (browserNeedsFallback) { + var browserFallbackNote = document.createElement('div'); + browserFallbackNote.className = 'browser-fallback-note'; + browserFallbackNote.textContent = + '\u26A0 Remote browser is newer than client browser. ' + + 'Try `inspect fallback` if inspection fails.'; + browserSection.appendChild(browserFallbackNote); + } + if (majorChromeVersion >= MIN_VERSION_NEW_TAB) { var newPage = document.createElement('div'); newPage.className = 'open'; @@ -401,6 +432,12 @@ function populateRemoteTargets(devices) { row, 'close', sendTargetCommand.bind(null, 'close', page), false); } + if (browserNeedsFallback) { + addActionLink( + row, 'inspect fallback', + sendTargetCommand.bind(null, 'inspect-fallback', page), + page.hasNoUniqueId || page.adbAttachedForeign); + } } } updateBrowserVisibility(browserSection); diff --git a/chromium/chrome/browser/resources/local_discovery/local_discovery.html b/chromium/chrome/browser/resources/local_discovery/local_discovery.html index 1ba6126155d..a99ecdcb5c5 100644 --- a/chromium/chrome/browser/resources/local_discovery/local_discovery.html +++ b/chromium/chrome/browser/resources/local_discovery/local_discovery.html @@ -26,6 +26,7 @@ <h1>$i18n{confirmRegistration}</h1> <div class="dialog-contents"> <div id="register-message"> + $i18nRaw{registerPrinterInformationMessage} </div> <div class="button-list"> @@ -38,10 +39,8 @@ </a> </div> <button class="register-cancel">$i18n{cancel}</button> - <button id="register-continue-button"> - $i18n{serviceRegister} - </button> - </div> + <button id="register-continue">$i18n{confirm}</button> + </div> </div> </div> diff --git a/chromium/chrome/browser/resources/local_discovery/local_discovery.js b/chromium/chrome/browser/resources/local_discovery/local_discovery.js index 6906a948908..0b9bc336cc9 100644 --- a/chromium/chrome/browser/resources/local_discovery/local_discovery.js +++ b/chromium/chrome/browser/resources/local_discovery/local_discovery.js @@ -118,6 +118,7 @@ cr.define('local_discovery', function() { deviceContainer: function() { return $('register-device-list'); }, + /** * Register the device. */ @@ -133,11 +134,8 @@ cr.define('local_discovery', function() { */ showRegister: function() { recordUmaEvent(DEVICES_PAGE_EVENTS.REGISTER_CLICKED); - $('register-message').textContent = loadTimeData.getStringF( - isPrinter(this.info.type) ? 'registerPrinterConfirmMessage' : - 'registerDeviceConfirmMessage', - this.info.display_name); - $('register-continue-button').onclick = this.register.bind(this); + $('register-continue').onclick = this.register.bind(this); + showRegisterOverlay(); }, /** @@ -521,7 +519,7 @@ cr.define('local_discovery', function() { isUserLoggedIn || isUserSupervisedOrOffTheRecord; $('register-overlay-login-promo').hidden = isUserLoggedIn || isUserSupervisedOrOffTheRecord; - $('register-continue-button').disabled = + $('register-continue').disabled = !isUserLoggedIn || isUserSupervisedOrOffTheRecord; $('my-devices-container').hidden = userSupervisedOrOffTheRecord; diff --git a/chromium/chrome/browser/resources/local_ntp/local_ntp.css b/chromium/chrome/browser/resources/local_ntp/local_ntp.css index 2b38a36fc16..343afa804b9 100644 --- a/chromium/chrome/browser/resources/local_ntp/local_ntp.css +++ b/chromium/chrome/browser/resources/local_ntp/local_ntp.css @@ -92,6 +92,8 @@ button { display: flex; flex-direction: column; height: 100%; + position: relative; + z-index: 1; } #logo, @@ -536,7 +538,6 @@ html[dir=rtl] #attribution, right: 0; top: 0; transition: opacity 130ms; - z-index: 1; } #one-google.hidden { diff --git a/chromium/chrome/browser/resources/local_ntp/most_visited_single.js b/chromium/chrome/browser/resources/local_ntp/most_visited_single.js index c4c0bfe2da7..9bdd106296f 100644 --- a/chromium/chrome/browser/resources/local_ntp/most_visited_single.js +++ b/chromium/chrome/browser/resources/local_ntp/most_visited_single.js @@ -27,35 +27,6 @@ var LOG_TYPE = { /** - * The different sources where an NTP tile's title can originate from. - * Note: Keep in sync with components/ntp_tiles/tile_title_source.h - * @enum {number} - * @const - */ -var TileTitleSource = { - UNKNOWN: 0, - MANIFEST: 1, - META_TAG: 2, - TITLE: 3, - INFERRED: 4 -}; - - -/** - * The different sources that an NTP tile can have. - * Note: Keep in sync with components/ntp_tiles/tile_source.h - * @enum {number} - * @const - */ -var TileSource = { - TOP_SITES: 0, - SUGGESTIONS_SERVICE: 1, - POPULAR: 3, - WHITELIST: 4, -}; - - -/** * The different (visual) types that an NTP tile can have. * Note: Keep in sync with components/ntp_tiles/tile_visual_type.h * @enum {number} @@ -128,9 +99,11 @@ var logEvent = function(eventType) { /** * Log impression of an NTP tile. * @param {number} tileIndex Position of the tile, >= 0 and < NUMBER_OF_TILES. - * @param {number} tileTitleSource The title's source from TileTitleSource. - * @param {number} tileSource The source from TileSource. - * @param {number} tileType The type from TileVisualType. + * @param {number} tileTitleSource The source of the tile's title as received + * from getMostVisitedItemData. + * @param {number} tileSource The tile's source as received from + * getMostVisitedItemData. + * @param {number} tileType The tile's visual type from TileVisualType. * @param {Date} dataGenerationTime Timestamp representing when the tile was * produced by a ranking algorithm. */ @@ -143,9 +116,11 @@ function logMostVisitedImpression( /** * Log click on an NTP tile. * @param {number} tileIndex Position of the tile, >= 0 and < NUMBER_OF_TILES. - * @param {number} tileTitleSource The title's source from TileTitleSource. - * @param {number} tileSource The source from TileSource. - * @param {number} tileType The type from TileVisualType. + * @param {number} tileTitleSource The source of the tile's title as received + * from getMostVisitedItemData. + * @param {number} tileSource The tile's source as received from + * getMostVisitedItemData. + * @param {number} tileType The tile's visual type from TileVisualType. * @param {Date} dataGenerationTime Timestamp representing when the tile was * produced by a ranking algorithm. */ diff --git a/chromium/chrome/browser/resources/md_bookmarks/app.html b/chromium/chrome/browser/resources/md_bookmarks/app.html index 64393cde7f8..82c01245dd4 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/app.html +++ b/chromium/chrome/browser/resources/md_bookmarks/app.html @@ -30,6 +30,7 @@ display: flex; flex-direction: row; flex-grow: 1; + overflow: hidden; } #splitter { diff --git a/chromium/chrome/browser/resources/md_bookmarks/bookmarks.html b/chromium/chrome/browser/resources/md_bookmarks/bookmarks.html index 6b15d439bb8..a12bb061aab 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/bookmarks.html +++ b/chromium/chrome/browser/resources/md_bookmarks/bookmarks.html @@ -6,6 +6,11 @@ <link rel="stylesheet" href="chrome://resources/css/text_defaults_md.css"> <link rel="stylesheet" href="chrome://resources/css/md_colors.css"> <style> + html { + /* Remove 300ms delay for 'click' event, when using touch interface. */ + touch-action: manipulation; + } + html, body { background: var(--md-background-color); diff --git a/chromium/chrome/browser/resources/md_bookmarks/command_manager.html b/chromium/chrome/browser/resources/md_bookmarks/command_manager.html index 508b0501a43..13b59a66782 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/command_manager.html +++ b/chromium/chrome/browser/resources/md_bookmarks/command_manager.html @@ -2,6 +2,7 @@ <link rel="import" href="chrome://resources/cr_elements/cr_action_menu/cr_action_menu.html"> <link rel="import" href="chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.html"> +<link rel="import" href="chrome://resources/cr_elements/paper_button_style_css.html"> <link rel="import" href="chrome://resources/html/cr/ui/command.html"> <link rel="import" href="chrome://resources/polymer/v1_0/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html"> <link rel="import" href="chrome://bookmarks/dialog_focus_manager.html"> @@ -11,7 +12,7 @@ <dom-module id="bookmarks-command-manager"> <template> - <style include="shared-style"> + <style include="shared-style paper-button-style"> .label { flex: 1; } @@ -30,7 +31,7 @@ <template is="cr-lazy-render" id="dropdown"> <dialog is="cr-action-menu" on-mousedown="onMenuMousedown_"> <template is="dom-repeat" items="[[menuCommands_]]" as="command"> - <button class="dropdown-item" + <button slot="item" class="dropdown-item" command$="[[command]]" hidden$="[[!isCommandVisible_(command, menuIds_)]]" disabled$="[[!isCommandEnabled_(command, menuIds_)]]" @@ -56,10 +57,10 @@ <div slot="title">$i18n{openDialogTitle}</div> <div slot="body"></div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onOpenCancelTap_"> + <paper-button class="cancel-button" on-click="onOpenCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onOpenConfirmTap_"> + <paper-button class="action-button" on-click="onOpenConfirmTap_"> $i18n{openDialogConfirm} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/md_bookmarks/command_manager.js b/chromium/chrome/browser/resources/md_bookmarks/command_manager.js index 668a4497522..900da62a5f6 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/command_manager.js +++ b/chromium/chrome/browser/resources/md_bookmarks/command_manager.js @@ -58,21 +58,6 @@ cr.define('bookmarks', function() { }); this.updateFromStore(); - /** @private {function(!Event)} */ - this.boundOnOpenCommandMenu_ = this.onOpenCommandMenu_.bind(this); - document.addEventListener( - 'open-command-menu', this.boundOnOpenCommandMenu_); - - /** @private {function()} */ - this.boundOnCommandUndo_ = () => { - this.handle(Command.UNDO, new Set()); - }; - document.addEventListener('command-undo', this.boundOnCommandUndo_); - - /** @private {function(!Event)} */ - this.boundOnKeydown_ = this.onKeydown_.bind(this); - document.addEventListener('keydown', this.boundOnKeydown_); - /** @private {!Map<Command, cr.ui.KeyboardShortcutList>} */ this.shortcuts_ = new Map(); @@ -92,14 +77,40 @@ cr.define('bookmarks', function() { this.addShortcut_(Command.CUT, 'Ctrl|x', 'Meta|x'); this.addShortcut_(Command.COPY, 'Ctrl|c', 'Meta|c'); this.addShortcut_(Command.PASTE, 'Ctrl|v', 'Meta|v'); + + /** @private {!Map<string, Function>} */ + this.boundListeners_ = new Map(); + + const addDocumentListener = (eventName, handler) => { + assert(!this.boundListeners_.has(eventName)); + const boundListener = handler.bind(this); + this.boundListeners_.set(eventName, boundListener); + document.addEventListener(eventName, boundListener); + }; + addDocumentListener('open-command-menu', this.onOpenCommandMenu_); + addDocumentListener('keydown', this.onKeydown_); + + const addDocumentListenerForCommand = (eventName, command) => { + addDocumentListener(eventName, (e) => { + if (e.path[0].tagName == 'INPUT') + return; + + const items = this.getState().selection.items; + if (this.canExecute(command, items)) + this.handle(command, items); + }); + }; + addDocumentListenerForCommand('command-undo', Command.UNDO); + addDocumentListenerForCommand('cut', Command.CUT); + addDocumentListenerForCommand('copy', Command.COPY); + addDocumentListenerForCommand('paste', Command.PASTE); }, detached: function() { CommandManager.instance_ = null; - document.removeEventListener( - 'open-command-menu', this.boundOnOpenCommandMenu_); - document.removeEventListener('command-undo', this.boundOnCommandUndo_); - document.removeEventListener('keydown', this.boundOnKeydown_); + this.boundListeners_.forEach( + (handler, eventName) => + document.removeEventListener(eventName, handler)); }, /** diff --git a/chromium/chrome/browser/resources/md_bookmarks/edit_dialog.html b/chromium/chrome/browser/resources/md_bookmarks/edit_dialog.html index c9e6ce563d9..2be1feb461a 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/edit_dialog.html +++ b/chromium/chrome/browser/resources/md_bookmarks/edit_dialog.html @@ -2,6 +2,7 @@ <link rel="import" href="chrome://resources/html/assert.html"> <link rel="import" href="chrome://resources/cr_elements/cr_dialog/cr_dialog.html"> +<link rel="import" href="chrome://resources/cr_elements/paper_button_style_css.html"> <link rel="import" href="chrome://resources/cr_elements/shared_style_css.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-input/paper-input.html"> @@ -9,7 +10,7 @@ <dom-module id="bookmarks-edit-dialog"> <template> - <style include="cr-shared-style"></style> + <style include="cr-shared-style paper-button-style"></style> <dialog is="cr-dialog" id="dialog"> <div slot="title"> [[getDialogTitle_(isFolder_, isEdit_)]] @@ -30,11 +31,11 @@ </paper-input> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelButtonTap_"> + <paper-button class="cancel-button" on-click="onCancelButtonTap_"> $i18n{cancel} </paper-button> <paper-button id="saveButton" class="action-button" - on-tap="onSaveButtonTap_"> + on-click="onSaveButtonTap_"> $i18n{saveEdit} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/md_bookmarks/folder_node.html b/chromium/chrome/browser/resources/md_bookmarks/folder_node.html index e8d13978fb4..c1a85652d3b 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/folder_node.html +++ b/chromium/chrome/browser/resources/md_bookmarks/folder_node.html @@ -76,7 +76,7 @@ <div id="container" class="v-centered" - on-tap="selectFolder_" + on-click="selectFolder_" on-dblclick="toggleFolder_" on-contextmenu="onContextMenu_" tabindex$="[[getTabIndex_(selectedFolder_, itemId)]]" @@ -85,7 +85,7 @@ <template is="dom-if" if="[[hasChildFolder_]]"> <button is="paper-icon-button-light" id="arrow" - on-tap="toggleFolder_" + on-click="toggleFolder_" on-mousedown="preventDefault_" tabindex="-1" aria-label$="[[getButtonAriaLabel_(isOpen, item_)]]"> @@ -98,7 +98,7 @@ open$="[[isSelectedFolder_]]" no-children$="[[!hasChildFolder_]]"> </div> - <div class="menu-label elided-text"" title="[[item_.title]]"> + <div class="menu-label elided-text" title="[[item_.title]]"> [[item_.title]] </div> </div> diff --git a/chromium/chrome/browser/resources/md_bookmarks/folder_node.js b/chromium/chrome/browser/resources/md_bookmarks/folder_node.js index 78008a9655f..4b913c3ea09 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/folder_node.js +++ b/chromium/chrome/browser/resources/md_bookmarks/folder_node.js @@ -141,7 +141,7 @@ Polymer({ changeKeyboardSelection_: function(xDirection, yDirection, currentFocus) { let newFocusFolderNode = null; const isChildFolderNodeFocused = - currentFocus.tagName == 'BOOKMARKS-FOLDER-NODE'; + currentFocus && currentFocus.tagName == 'BOOKMARKS-FOLDER-NODE'; if (xDirection == 1) { // The right arrow opens a folder if closed and goes to the first child diff --git a/chromium/chrome/browser/resources/md_bookmarks/list.html b/chromium/chrome/browser/resources/md_bookmarks/list.html index 1927916322b..ddbf28f533c 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/list.html +++ b/chromium/chrome/browser/resources/md_bookmarks/list.html @@ -21,7 +21,7 @@ } #list { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background-color: #fff; margin: 0 auto; max-width: var(--card-max-width); diff --git a/chromium/chrome/browser/resources/md_bookmarks/list.js b/chromium/chrome/browser/resources/md_bookmarks/list.js index 3b6aa847a67..11b1580b221 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/list.js +++ b/chromium/chrome/browser/resources/md_bookmarks/list.js @@ -142,7 +142,13 @@ Polymer({ /** @private */ emptyListMessage_: function() { - const emptyListMessage = this.searchTerm_ ? 'noSearchResults' : 'emptyList'; + let emptyListMessage = 'noSearchResults'; + if (!this.searchTerm_) { + emptyListMessage = bookmarks.util.canReorderChildren( + this.getState(), this.getState().selectedFolder) ? + 'emptyList' : + 'emptyUnmodifiableList'; + } return loadTimeData.getString(emptyListMessage); }, diff --git a/chromium/chrome/browser/resources/md_bookmarks/toast_manager.html b/chromium/chrome/browser/resources/md_bookmarks/toast_manager.html index f94f6342f80..454d1f1ab70 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/toast_manager.html +++ b/chromium/chrome/browser/resources/md_bookmarks/toast_manager.html @@ -2,11 +2,12 @@ <link rel="import" href="chrome://resources/polymer/v1_0/iron-a11y-announcer/iron-a11y-announcer.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> <link rel="import" href="chrome://resources/cr_elements/cr_toast/cr_toast.html"> +<link rel="import" href="chrome://resources/cr_elements/paper_button_style_css.html"> <link rel="import" href="chrome://bookmarks/shared_style.html"> <dom-module id="bookmarks-toast-manager"> <template> - <style include="shared-style"> + <style include="shared-style paper-button-style"> #content { color: #fff; display: flex; @@ -17,9 +18,7 @@ -webkit-margin-end: 0; -webkit-margin-start: 32px; color: var(--google-blue-300); - font-weight: 500; height: 32px; - min-width: 52px; padding: 8px; } @@ -34,7 +33,7 @@ </style> <cr-toast id="toast" duration="[[duration]]"> <div id="content" class="elided-text"></div> - <paper-button id="button" hidden$="[[!showUndo_]]" on-tap="onUndoTap_"> + <paper-button id="button" hidden$="[[!showUndo_]]" on-click="onUndoTap_"> $i18n{undo} </paper-button> </cr-toast> diff --git a/chromium/chrome/browser/resources/md_bookmarks/toolbar.html b/chromium/chrome/browser/resources/md_bookmarks/toolbar.html index 219c383cd75..4f8b2786f45 100644 --- a/chromium/chrome/browser/resources/md_bookmarks/toolbar.html +++ b/chromium/chrome/browser/resources/md_bookmarks/toolbar.html @@ -52,7 +52,7 @@ id="menuButton" class="more-actions more-vert-button" title="$i18n{organizeButtonTitle}" - on-tap="onMenuButtonOpenTap_" + on-click="onMenuButtonOpenTap_" aria-haspopup="menu"> <div></div> <div></div> diff --git a/chromium/chrome/browser/resources/md_downloads/1x/incognito_marker.png b/chromium/chrome/browser/resources/md_downloads/1x/incognito_marker.png Binary files differindex 2668850b677..4d7b4f0457a 100644 --- a/chromium/chrome/browser/resources/md_downloads/1x/incognito_marker.png +++ b/chromium/chrome/browser/resources/md_downloads/1x/incognito_marker.png diff --git a/chromium/chrome/browser/resources/md_downloads/1x/no_downloads.png b/chromium/chrome/browser/resources/md_downloads/1x/no_downloads.png Binary files differindex e445faebe3b..9cb8a7cab08 100644 --- a/chromium/chrome/browser/resources/md_downloads/1x/no_downloads.png +++ b/chromium/chrome/browser/resources/md_downloads/1x/no_downloads.png diff --git a/chromium/chrome/browser/resources/md_downloads/2x/incognito_marker.png b/chromium/chrome/browser/resources/md_downloads/2x/incognito_marker.png Binary files differindex 3ee7c32a700..af60569dd0d 100644 --- a/chromium/chrome/browser/resources/md_downloads/2x/incognito_marker.png +++ b/chromium/chrome/browser/resources/md_downloads/2x/incognito_marker.png diff --git a/chromium/chrome/browser/resources/md_downloads/2x/no_downloads.png b/chromium/chrome/browser/resources/md_downloads/2x/no_downloads.png Binary files differindex 90edf87a248..6181df50770 100644 --- a/chromium/chrome/browser/resources/md_downloads/2x/no_downloads.png +++ b/chromium/chrome/browser/resources/md_downloads/2x/no_downloads.png diff --git a/chromium/chrome/browser/resources/md_downloads/compiled_resources2.gyp b/chromium/chrome/browser/resources/md_downloads/compiled_resources2.gyp index b9e2ca905c3..4adfce2fe86 100644 --- a/chromium/chrome/browser/resources/md_downloads/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/md_downloads/compiled_resources2.gyp @@ -75,7 +75,6 @@ '<(DEPTH)/ui/webui/resources/cr_elements/cr_action_menu/compiled_resources2.gyp:cr_action_menu', '<(DEPTH)/ui/webui/resources/cr_elements/cr_toolbar/compiled_resources2.gyp:cr_toolbar', '<(DEPTH)/third_party/polymer/v1_0/components-chromium/iron-a11y-announcer/compiled_resources2.gyp:iron-a11y-announcer-extracted', - '<(DEPTH)/third_party/polymer/v1_0/components-chromium/paper-menu/compiled_resources2.gyp:paper-menu-extracted', 'browser_proxy', 'search_service', ], diff --git a/chromium/chrome/browser/resources/md_downloads/downloads.html b/chromium/chrome/browser/resources/md_downloads/downloads.html index bc126bb68c6..07eaf62e051 100644 --- a/chromium/chrome/browser/resources/md_downloads/downloads.html +++ b/chromium/chrome/browser/resources/md_downloads/downloads.html @@ -9,6 +9,9 @@ --downloads-card-margin: 24px; --downloads-card-width: 680px; background: #f1f1f1; + + /* Remove 300ms delay for 'click' event, when using touch interface. */ + touch-action: manipulation; } .loading { diff --git a/chromium/chrome/browser/resources/md_downloads/item.html b/chromium/chrome/browser/resources/md_downloads/item.html index a230d16a3d3..3696956b9a7 100644 --- a/chromium/chrome/browser/resources/md_downloads/item.html +++ b/chromium/chrome/browser/resources/md_downloads/item.html @@ -54,7 +54,7 @@ } #content.is-active { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; } #content:not(.is-active) { @@ -244,7 +244,7 @@ <div id="title-area"><!-- Can't have any line breaks. --><a is="action-link" id="file-link" href="[[data.url]]" - on-tap="onFileLinkTap_" + on-click="onFileLinkTap_" hidden="[[!completelyOnDisk_]]">[[data.file_name]]</a><!-- Before #name. --><span id="name" @@ -263,20 +263,20 @@ </template> <div id="safe" class="controls" hidden="[[isDangerous_]]"> - <a is="action-link" id="show" on-tap="onShowTap_" + <a is="action-link" id="show" on-click="onShowTap_" hidden="[[!completelyOnDisk_]]">$i18n{controlShowInFolder}</a> <template is="dom-if" if="[[data.retry]]"> - <paper-button id="retry" on-tap="onRetryTap_"> + <paper-button id="retry" on-click="onRetryTap_"> $i18n{controlRetry} </paper-button> </template> <template is="dom-if" if="[[pauseOrResumeText_]]"> - <paper-button id="pause-or-resume" on-tap="onPauseOrResumeTap_"> + <paper-button id="pause-or-resume" on-click="onPauseOrResumeTap_"> [[pauseOrResumeText_]] </paper-button> </template> <template is="dom-if" if="[[showCancel_]]"> - <paper-button id="cancel" on-tap="onCancelTap_"> + <paper-button id="cancel" on-click="onCancelTap_"> $i18n{controlCancel} </paper-button> </template> @@ -287,17 +287,17 @@ <div id="dangerous" class="controls"> <!-- Dangerous file types (e.g. .exe, .jar). --> <template is="dom-if" if="[[!isMalware_]]"> - <paper-button id="discard" on-tap="onDiscardDangerousTap_" + <paper-button id="discard" on-click="onDiscardDangerousTap_" class="discard">$i18n{dangerDiscard}</paper-button> - <paper-button id="save" on-tap="onSaveDangerousTap_" + <paper-button id="save" on-click="onSaveDangerousTap_" class="keep">$i18n{dangerSave}</paper-button> </template> <!-- Things that safe browsing has determined to be dangerous. --> <template is="dom-if" if="[[isMalware_]]"> - <paper-button id="danger-remove" on-tap="onDiscardDangerousTap_" + <paper-button id="danger-remove" on-click="onDiscardDangerousTap_" class="discard">$i18n{controlRemoveFromList}</paper-button> - <paper-button id="restore" on-tap="onSaveDangerousTap_" + <paper-button id="restore" on-click="onSaveDangerousTap_" class="keep">$i18n{dangerRestore}</paper-button> </template> </div> @@ -307,8 +307,9 @@ <div id="remove-wrapper" class="icon-wrapper"> <button is="paper-icon-button-light" id="remove" title="$i18n{controlRemoveFromList}" + aria-label="$i18n{controlRemoveFromList}" style$="[[computeRemoveStyle_(isDangerous_, showCancel_)]]" - on-tap="onRemoveTap_">✕</button> + on-click="onRemoveTap_">✕</button> </div> <div id="incognito" title="$i18n{inIncognito}" hidden="[[!data.otr]]"> diff --git a/chromium/chrome/browser/resources/md_downloads/manager.html b/chromium/chrome/browser/resources/md_downloads/manager.html index c8c68366be5..3662b4d2e1a 100644 --- a/chromium/chrome/browser/resources/md_downloads/manager.html +++ b/chromium/chrome/browser/resources/md_downloads/manager.html @@ -23,6 +23,7 @@ flex: 1 0; flex-direction: column; height: 100%; + overflow: hidden; z-index: 0; } @@ -39,7 +40,7 @@ } #drop-shadow { - @apply(--cr-container-shadow); + @apply --cr-container-shadow; } :host([has-shadow_]) #drop-shadow { diff --git a/chromium/chrome/browser/resources/md_downloads/toolbar.html b/chromium/chrome/browser/resources/md_downloads/toolbar.html index 24209d11177..a98f6b02ca5 100644 --- a/chromium/chrome/browser/resources/md_downloads/toolbar.html +++ b/chromium/chrome/browser/resources/md_downloads/toolbar.html @@ -41,15 +41,17 @@ spinner-active="{{spinnerActive}}" on-search-changed="onSearchChanged_"> <button is="paper-icon-button-light" id="moreActions" title="$i18n{moreActions}" class="dropdown-trigger" - on-tap="onMoreActionsTap_"> + on-click="onMoreActionsTap_"> <iron-icon icon="cr:more-vert"></iron-icon> </button> </cr-toolbar> <dialog is="cr-action-menu" id="moreActionsMenu"> - <button class="dropdown-item clear-all" on-tap="onClearAllTap_"> + <button slot="item" class="dropdown-item clear-all" + on-click="onClearAllTap_"> $i18n{clearAll} - </div> - <button class="dropdown-item" on-tap="onOpenDownloadsFolderTap_"> + </button> + <button slot="item" class="dropdown-item" + on-click="onOpenDownloadsFolderTap_"> $i18n{openDownloadsFolder} </button> </dialog> diff --git a/chromium/chrome/browser/resources/md_extensions/code_section.html b/chromium/chrome/browser/resources/md_extensions/code_section.html index 093e9a62042..5d575bae3f5 100644 --- a/chromium/chrome/browser/resources/md_extensions/code_section.html +++ b/chromium/chrome/browser/resources/md_extensions/code_section.html @@ -2,6 +2,7 @@ <link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> <link rel="import" href="chrome://resources/html/cr.html"> +<link rel="import" href="chrome://resources/html/i18n_behavior.html"> <link rel="import" href="chrome://resources/html/load_time_data.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-styles/color.html"> @@ -54,10 +55,16 @@ .more-code { color: var(--paper-grey-500); } + + #highlight-description { + height: 0; + overflow: hidden; + } </style> - <div id="scroll-container" hidden="[[!codeText_]]"> + <div id="scroll-container" hidden="[[!highlighted_]]"> <div id="main"> - <div id="line-numbers"> + <!-- Line numbers are not useful to a screenreader --> + <div id="line-numbers" aria-hidden="true"> <div class="more-code before" hidden="[[!truncatedBefore_]]"> ... </div> @@ -73,7 +80,14 @@ '$i18nPolymer{errorLinesNotShownSingular}', '$i18nPolymer{errorLinesNotShownPlural}')]] </div> - <span>[[codeText_]]</span> + <span><!-- Whitespace is preserved in this span. Ignore new lines. + --><span>[[before_]]</span><!-- + --><mark aria-label$="[[highlighted_]]" + aria-describedby="highlight-description"><!-- + --><span aria-hidden="true">[[highlighted_]]</span><!-- + --></mark><!-- + --><span>[[after_]]</span><!-- + --></span> <div class="more-code after" hidden="[[!truncatedAfter_]]"> [[getLinesNotShownLabel_( truncatedAfter_, @@ -83,7 +97,10 @@ </div> </div> </div> - <div id="no-code" hidden="[[codeText_]]">[[couldNotDisplayCode]]</div> + <div id="no-code" hidden="[[highlighted_]]">[[couldNotDisplayCode]]</div> + <div id="highlight-description" aria-hidden="true"> + [[highlightDescription_]] + </div> </template> <script src="code_section.js"></script> </dom-module> diff --git a/chromium/chrome/browser/resources/md_extensions/code_section.js b/chromium/chrome/browser/resources/md_extensions/code_section.js index 149d5d7d054..001dad3a045 100644 --- a/chromium/chrome/browser/resources/md_extensions/code_section.js +++ b/chromium/chrome/browser/resources/md_extensions/code_section.js @@ -21,6 +21,8 @@ cr.define('extensions', function() { const CodeSection = Polymer({ is: 'extensions-code-section', + behaviors: [I18nBehavior], + properties: { /** * The code this object is displaying. @@ -31,13 +33,17 @@ cr.define('extensions', function() { value: null, }, - /** - * The text of the entire source file. This value does not update on - * highlight changes; it only updates if the content of the source - * changes. - * @private - */ - codeText_: String, + /** @private Highlighted code. */ + highlighted_: String, + + /** @private Code before the highlighted section. */ + before_: String, + + /** @private Code after the highlighted section. */ + after_: String, + + /** @private Description for the highlighted section. */ + highlightDescription_: String, /** @private */ lineNumbers_: String, @@ -67,7 +73,10 @@ cr.define('extensions', function() { if (!this.code || (!this.code.beforeHighlight && !this.code.highlight && !this.code.afterHighlight)) { - this.codeText_ = ''; + this.highlighted_ = ''; + this.highlightDescription_ = ''; + this.before_ = ''; + this.after_ = ''; this.lineNumbers_ = ''; return; } @@ -91,15 +100,19 @@ cr.define('extensions', function() { if (visibleAfter.charAt(visibleAfter.length - 1) == '\n') visibleAfter += ' '; - this.codeText_ = visibleBefore + highlight + visibleAfter; + this.highlighted_ = highlight; + this.highlightDescription_ = this.getAccessibilityHighlightDescription_( + linesBefore.length, highlight.split('\n').length); + this.before_ = visibleBefore; + this.after_ = visibleAfter; this.truncatedBefore_ = linesBefore.length - visibleLineCountBefore; this.truncatedAfter_ = linesAfter.length - visibleLineCountAfter; + let visibleCode = visibleBefore + highlight + visibleAfter; + this.setLineNumbers_( this.truncatedBefore_ + 1, - this.truncatedBefore_ + this.codeText_.split('\n').length); - this.createHighlight_( - visibleBefore.length, visibleBefore.length + highlight.length); + this.truncatedBefore_ + visibleCode.split('\n').length); this.scrollToHighlight_(visibleLineCountBefore); }, @@ -130,23 +143,6 @@ cr.define('extensions', function() { }, /** - * Uses the native text-selection API to highlight desired code. - * @param {number} start - * @param {number} end - * @private - */ - createHighlight_: function(start, end) { - const range = document.createRange(); - const node = this.$.source.querySelector('span').firstChild; - range.setStart(node, start); - range.setEnd(node, end); - - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }, - - /** * @param {number} linesBeforeHighlight * @private */ @@ -161,6 +157,22 @@ cr.define('extensions', function() { this.$['scroll-container'].scrollTo({top: targetTop}); }, + + /** + * @param {number} lineStart + * @param {number} numLines + * @return {string} + * @private + */ + getAccessibilityHighlightDescription_: function(lineStart, numLines) { + if (numLines > 1) { + return this.i18n( + 'accessibilityErrorMultiLine', lineStart.toString(), + (lineStart + numLines - 1).toString()); + } else { + return this.i18n('accessibilityErrorLine', lineStart.toString()); + } + }, }); return {CodeSection: CodeSection}; diff --git a/chromium/chrome/browser/resources/md_extensions/compiled_resources2.gyp b/chromium/chrome/browser/resources/md_extensions/compiled_resources2.gyp index 74850796458..c4c7924b659 100644 --- a/chromium/chrome/browser/resources/md_extensions/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/md_extensions/compiled_resources2.gyp @@ -7,6 +7,7 @@ 'target_name': 'code_section', 'dependencies': [ '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:load_time_data', '<(EXTERNS_GYP):developer_private', ], @@ -50,7 +51,6 @@ { 'target_name': 'error_page', 'dependencies': [ - '<(DEPTH)/third_party/polymer/v1_0/components-chromium/paper-menu/compiled_resources2.gyp:paper-menu-extracted', '<(DEPTH)/ui/webui/resources/cr_elements/compiled_resources2.gyp:cr_container_shadow_behavior', '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', '<(DEPTH)/ui/webui/resources/js/cr/ui/compiled_resources2.gyp:focus_outline_manager', diff --git a/chromium/chrome/browser/resources/md_extensions/detail_view.html b/chromium/chrome/browser/resources/md_extensions/detail_view.html index a111266af18..f730ba9363d 100644 --- a/chromium/chrome/browser/resources/md_extensions/detail_view.html +++ b/chromium/chrome/browser/resources/md_extensions/detail_view.html @@ -55,7 +55,7 @@ } #main { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background-color: white; margin: auto; min-height: 100%; @@ -80,7 +80,7 @@ #name { flex-grow: 1; - @apply(--cr-title-text); + @apply --cr-title-text; } #learn-more-link { @@ -93,7 +93,7 @@ } .section { - @apply(--cr-section); + @apply --cr-section; } .section.block { @@ -144,6 +144,10 @@ width: 78px; } + #reload-button { + color: var(--google-blue-500); + } + .warning div { display: flex; } @@ -155,9 +159,20 @@ .warning-icon { --iron-icon-fill-color: var(--paper-red-700); - --iron-icon-height: 19px; - --iron-icon-width: 19px; - margin-right: 16px; + -webkit-margin-end: 16px; + height: 19px; + width: 19px; + } + + #error-icon { + --iron-icon-fill-color: var(--google-red-700); + -webkit-margin-end: 4px; + height: 18px; + width: 18px; + } + + #runtime-warnings { + color: var(--google-red-700); } ul { @@ -187,7 +202,7 @@ } paper-spinner-lite { - @apply(--cr-icon-height-width); + @apply --cr-icon-height-width; } </style> <div id="container"> @@ -195,7 +210,7 @@ <div id="top-bar"> <button id="close-button" is="paper-icon-button-light" aria-label="$i18n{back}" class="icon-arrow-back no-overlap" - on-tap="onCloseButtonTap_"></button> + on-click="onCloseButtonTap_"></button> <img id="icon" src="[[data.iconUrl]]" alt$="[[appOrExtension( data.type, @@ -227,6 +242,19 @@ </div> </div> <div id="warnings" hidden$="[[!hasWarnings_(data.*)]]"> + <div id="runtime-warnings" aria-describedby="a11yAssociation" + hidden$="[[!data.runtimeWarnings.length]]" + class="section continuation control-line"> + <div> + <iron-icon id="error-icon" icon="error"></iron-icon> + <template is="dom-repeat" items="[[data.runtimeWarnings]]"> + [[item]] + </template> + </div> + <paper-button id="reload-button" on-click="onReloadTap_"> + $i18n{itemReload} + </paper-button> + </div> <div class="section continuation warning" id="suspicious-warning" hidden$="[[!data.disableReasons.suspiciousInstall]]"> <div> @@ -247,7 +275,7 @@ <span>$i18n{itemCorruptInstall}</span> </div> <paper-button id="repair-button" class="action-button" - on-tap="onRepairTap_"> + on-click="onRepairTap_"> $i18n{itemRepair} </paper-button> </div> @@ -299,7 +327,7 @@ <template is="dom-repeat" items="[[data.views]]"> <li> <a is="action-link" class="inspectable-view" - on-tap="onInspectTap_"> + on-click="onInspectTap_"> [[computeInspectLabel_(item)]] </a> </li> @@ -382,15 +410,15 @@ disabled="[[!isEnabled_(data.state)]]" hidden="[[!shouldShowOptionsLink_(data.*)]]" icon-class="icon-external" label="$i18n{itemOptions}" - on-tap="onExtensionOptionsTap_"> + on-click="onExtensionOptionsTap_"> </button> <button class="hr" hidden="[[!data.manifestHomePageUrl.length]]" is="cr-link-row" icon-class="icon-external" id="extensionWebsite" - label="$i18n{extensionWebsite}" on-tap="onExtensionWebSiteTap_"> + label="$i18n{extensionWebsite}" on-click="onExtensionWebSiteTap_"> </button> <button class="hr" hidden="[[!data.webStoreUrl.length]]" is="cr-link-row" icon-class="icon-external" id="viewInStore" - label="$i18n{viewInStore}" on-tap="onViewInStoreTap_"> + label="$i18n{viewInStore}" on-click="onViewInStoreTap_"> </button> <div class="section block"> <div class="section-title">$i18n{itemSource}</div> @@ -400,7 +428,7 @@ <div id="load-path" class="section-content" hidden$="[[!data.prettifiedPath]]"> <span>$i18n{itemExtensionPath}</span> - <a is="action-link" on-tap="onLoadPathTap_"> + <a is="action-link" on-click="onLoadPathTap_"> [[data.prettifiedPath]] </a> </div> @@ -408,7 +436,7 @@ <button class="hr" is="cr-link-row" hidden="[[isControlled_(data.controlledInfo)]]" icon-class="subpage-arrow" id="remove-extension" - label="$i18n{itemRemoveExtension}" on-tap="onRemoveTap_"> + label="$i18n{itemRemoveExtension}" on-click="onRemoveTap_"> </button> </div> </div> diff --git a/chromium/chrome/browser/resources/md_extensions/detail_view.js b/chromium/chrome/browser/resources/md_extensions/detail_view.js index 6115bc07b77..364eb45cf4a 100644 --- a/chromium/chrome/browser/resources/md_extensions/detail_view.js +++ b/chromium/chrome/browser/resources/md_extensions/detail_view.js @@ -101,7 +101,8 @@ cr.define('extensions', function() { hasWarnings_: function() { return this.data.disableReasons.corruptInstall || this.data.disableReasons.suspiciousInstall || - this.data.disableReasons.updateRequired || !!this.data.blacklistText; + this.data.disableReasons.updateRequired || + !!this.data.blacklistText || this.data.runtimeWarnings.length > 0; }, /** @@ -179,6 +180,13 @@ cr.define('extensions', function() { }, /** @private */ + onReloadTap_: function() { + this.delegate.reloadItem(this.data.id).catch(loadError => { + this.fire('load-error', loadError); + }); + }, + + /** @private */ onRemoveTap_: function() { this.delegate.deleteItem(this.data.id); }, diff --git a/chromium/chrome/browser/resources/md_extensions/error_page.html b/chromium/chrome/browser/resources/md_extensions/error_page.html index 2cae6446d16..e872d27f2a3 100644 --- a/chromium/chrome/browser/resources/md_extensions/error_page.html +++ b/chromium/chrome/browser/resources/md_extensions/error_page.html @@ -31,7 +31,7 @@ iron-icon { --iron-icon-fill-color: var(--paper-grey-500); - @apply(--cr-icon-height-width); + @apply --cr-icon-height-width; flex-shrink: 0; } @@ -47,7 +47,7 @@ * detail_view.html and error_page.html. Refactor such that no duplication * happens.*/ #main { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background-color: white; margin: auto; min-height: 100%; @@ -64,7 +64,7 @@ height: 40px; margin-bottom: 30px; padding: 8px 12px 0; - @apply(--cr-title-text); + @apply --cr-title-text; } #heading span { @@ -77,7 +77,7 @@ } .error-item { - @apply(--cr-section); + @apply --cr-section; padding-left: 0; } @@ -108,7 +108,7 @@ } .details-heading { - @apply(--cr-title-text); + @apply --cr-title-text; align-items: center; display: flex; height: var(--cr-section-min-height); @@ -177,10 +177,10 @@ <div id="heading"> <button id="close-button" is="paper-icon-button-light" aria-label="$i18n{back}" - class="icon-arrow-back no-overlap" on-tap="onCloseButtonTap_"> + class="icon-arrow-back no-overlap" on-click="onCloseButtonTap_"> </button> <span>$i18n{errorsPageHeading}</span> - <paper-button on-tap="onClearAllTap_" hidden="[[!entries_.length]]"> + <paper-button on-click="onClearAllTap_" hidden="[[!entries_.length]]"> $i18n{clearAll} </paper-button> </div> @@ -190,7 +190,7 @@ <div class="item-container"> <div class$="error-item [[computeErrorClass_(item, selectedEntry_)]]"> - <div actionable class=" start" on-tap="onErrorItemAction_" + <div actionable class=" start" on-click="onErrorItemAction_" on-keydown="onErrorItemAction_" tabindex="0" role="button"> <iron-icon icon$="[[computeErrorIcon_(item)]]" @@ -204,7 +204,7 @@ </div> <div class="separator"></div> <button is="paper-icon-button-light" class="icon-delete-gray" - on-tap="onDeleteErrorAction_" + on-click="onDeleteErrorAction_" aria-describedby$="[[item.id]]" aria-label="$i18n{clearEntry}" on-keydown="onDeleteErrorAction_"> @@ -226,7 +226,7 @@ </div> <ul class="stack-trace-container"> <template is="dom-repeat" items="[[item.stackTrace]]"> - <li on-tap="onStackFrameTap_" + <li on-click="onStackFrameTap_" hidden="[[!shouldDisplayFrame_(item.url)]]" class$="[[getStackFrameClass_(item, selectedStackFrame_)]]"> @@ -244,7 +244,7 @@ <paper-button class="devtool-button action-button" hidden$="[[!computeIsRuntimeError_(item)]]" disabled="[[!item.canInspect]]" - on-tap="onDevToolButtonTap_"> + on-click="onDevToolButtonTap_"> $i18n{openInDevtool} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/md_extensions/extensions.html b/chromium/chrome/browser/resources/md_extensions/extensions.html index 5b728b9ca9d..5b851ed3990 100644 --- a/chromium/chrome/browser/resources/md_extensions/extensions.html +++ b/chromium/chrome/browser/resources/md_extensions/extensions.html @@ -12,6 +12,9 @@ /* --md-background-color in disguise. Not using the var for increased * performance. */ background-color: #f1f1f1; + + /* Remove 300ms delay for 'click' event, when using touch interface. */ + touch-action: manipulation; } .loading { diff --git a/chromium/chrome/browser/resources/md_extensions/icons.html b/chromium/chrome/browser/resources/md_extensions/icons.html index 34714cc4e7b..f05904be4f3 100644 --- a/chromium/chrome/browser/resources/md_extensions/icons.html +++ b/chromium/chrome/browser/resources/md_extensions/icons.html @@ -57,4 +57,4 @@ </g> </defs> </svg> -</iron-icon-set> +</iron-iconset-svg> diff --git a/chromium/chrome/browser/resources/md_extensions/install_warnings_dialog.html b/chromium/chrome/browser/resources/md_extensions/install_warnings_dialog.html index d50ce39ff17..286b25e54d6 100644 --- a/chromium/chrome/browser/resources/md_extensions/install_warnings_dialog.html +++ b/chromium/chrome/browser/resources/md_extensions/install_warnings_dialog.html @@ -30,7 +30,7 @@ </ul> </div> <div slot="button-container"> - <paper-button class="action-button" on-tap="onOkTap_"> + <paper-button class="action-button" on-click="onOkTap_"> $i18n{ok} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/md_extensions/item.html b/chromium/chrome/browser/resources/md_extensions/item.html index f9f2d770906..f2fe3442f46 100644 --- a/chromium/chrome/browser/resources/md_extensions/item.html +++ b/chromium/chrome/browser/resources/md_extensions/item.html @@ -60,7 +60,7 @@ } #card { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background: white; border-radius: 2px; display: flex; @@ -90,7 +90,7 @@ } #name-and-version { - @apply(--cr-primary-text); + @apply --cr-primary-text; margin-bottom: 4px; } @@ -108,6 +108,13 @@ margin-bottom: 8px; } + #error-icon { + --iron-icon-fill-color: var(--google-red-700); + -webkit-margin-end: 4px; + height: 18px; + width: 18px; + } + #description, #version, #extension-id, @@ -177,7 +184,7 @@ paper-tooltip { --paper-tooltip: { - @apply(--cr-tooltip); + @apply --cr-tooltip; min-width: 0; }; } @@ -255,28 +262,29 @@ [[data.description]] </div> <template is="dom-if" if="[[hasWarnings_(data.*)]]"> - <div id="warnings" > - <div id="runtime-warnings" aria-describedby="a11yAssociation" + <div id="warnings"> + <iron-icon id="error-icon" icon="error"></iron-icon> + <span id="runtime-warnings" aria-describedby="a11yAssociation" hidden$="[[!data.runtimeWarnings.length]]"> <template is="dom-repeat" items="[[data.runtimeWarnings]]"> [[item]] </template> - </div> - <div id="suspicious-warning" aria-describedby="a11yAssociation" + </span> + <span id="suspicious-warning" aria-describedby="a11yAssociation" hidden$="[[!data.disableReasons.suspiciousInstall]]"> $i18n{itemSuspiciousInstall} <a target="_blank" id="learn-more-link" href="$i18n{suspiciousInstallHelpUrl}"> $i18n{learnMore} </a> - </div> - <div id="corrupted-warning" aria-describedby="a11yAssociation" + </span> + <span id="corrupted-warning" aria-describedby="a11yAssociation" hidden$="[[!data.disableReasons.corruptInstall]]"> $i18n{itemCorruptInstall} - </div> - <div id="blacklisted-warning"><!-- No whitespace + </span> + <span id="blacklisted-warning"><!-- No whitespace -->[[data.blacklistText]]<!-- so we can use :empty in css. - --></div> + --></span> </div> </template> <template is="dom-if" if="[[inDevMode]]"> @@ -292,12 +300,12 @@ </span> <a class="clippable-flex-text" is="action-link" title="[[computeFirstInspectTitle_(data.views)]]" - on-tap="onInspectTap_"> + on-click="onInspectTap_"> [[computeFirstInspectLabel_(data.views)]] </a> <a is="action-link" hidden$="[[computeExtraViewsHidden_(data.views)]]" - on-tap="onExtraInspectTap_"> + on-click="onExtraInspectTap_"> [[computeExtraInspectLabel_(data.views)]] </a> </div> @@ -308,17 +316,17 @@ </div> <div id="button-strip" class="layout horizontal center"> <div class="layout flex horizontal center"> - <paper-button id="details-button" on-tap="onDetailsTap_" + <paper-button id="details-button" on-click="onDetailsTap_" aria-describedby="a11yAssociation"> $i18n{itemDetails} </paper-button> - <paper-button id="remove-button" on-tap="onRemoveTap_" + <paper-button id="remove-button" on-click="onRemoveTap_" aria-describedby="a11yAssociation" hidden="[[isControlled_(data.controlledInfo)]]"> $i18n{itemRemove} </paper-button> <template is="dom-if" if="[[shouldShowErrorsButton_(data.*)]]"> - <paper-button id="errors-button" on-tap="onErrorsTap_" + <paper-button id="errors-button" on-click="onErrorsTap_" aria-describedby="a11yAssociation"> $i18n{itemErrors} </paper-button> @@ -327,22 +335,22 @@ <template is="dom-if" if="[[!computeDevReloadButtonHidden_(data.*)]]"> <button id="dev-reload-button" is="paper-icon-button-light" aria-label="$i18n{itemReload}" aria-describedby="a11yAssociation" - class="icon-refresh no-overlap" on-tap="onReloadTap_"> + class="icon-refresh no-overlap" on-click="onReloadTap_"> </button> </template> <template is="dom-if" if="[[data.disableReasons.corruptInstall]]"> <paper-button id="repair-button" class="action-button" - aria-describedby="a11yAssociation" on-tap="onRepairTap_"> + aria-describedby="a11yAssociation" on-click="onRepairTap_"> $i18n{itemRepair} </paper-button> </template> <template is="dom-if" if="[[isTerminated_(data.state)]]"> - <paper-button id="terminated-reload-button" on-tap="onReloadTap_" + <paper-button id="terminated-reload-button" on-click="onReloadTap_" aria-describedby="a11yAssociation" class="action-button"> $i18n{itemReload} </paper-button> </template> - <cr-toggle id="enable-toggle" class="action-button" + <cr-toggle id="enable-toggle" aria-label$="[[appOrExtension( data.type, '$i18nPolymer{appEnabled}', diff --git a/chromium/chrome/browser/resources/md_extensions/item.js b/chromium/chrome/browser/resources/md_extensions/item.js index 116f96d50ea..bdf69ab5bf5 100644 --- a/chromium/chrome/browser/resources/md_extensions/item.js +++ b/chromium/chrome/browser/resources/md_extensions/item.js @@ -109,7 +109,12 @@ cr.define('extensions', function() { /** @private string */ a11yAssociation_: function() { - return this.i18n('extensionA11yAssociation', this.data.name); + // Don't use I18nBehavior.i18n because of additional checks it performs. + // Polymer ensures that this string is not stamped into arbitrary HTML. + // |this.data.name| can contain any data including html tags. + // ex: "My <video> download extension!" + return loadTimeData.getStringF( + 'extensionA11yAssociation', this.data.name); }, /** @private */ diff --git a/chromium/chrome/browser/resources/md_extensions/item_list.html b/chromium/chrome/browser/resources/md_extensions/item_list.html index 2ac11babaa0..849590719b1 100644 --- a/chromium/chrome/browser/resources/md_extensions/item_list.html +++ b/chromium/chrome/browser/resources/md_extensions/item_list.html @@ -49,7 +49,7 @@ } #app-title { - @apply(--cr-section-text); + @apply --cr-section-text; margin-bottom: 12px; margin-top: 21px; } @@ -61,7 +61,7 @@ <div id="no-items" class="empty-list-message" hidden$="[[!shouldShowEmptyItemsMessage_( apps.length, extensions.length)]]"> - <span on-tap="onNoExtensionsTap_">$i18nRaw{noExtensionsOrApps}</span> + <span on-click="onNoExtensionsTap_">$i18nRaw{noExtensionsOrApps}</span> </div> <div id="no-search-results" class="empty-list-message" hidden$="[[!shouldShowEmptySearchMessage_( diff --git a/chromium/chrome/browser/resources/md_extensions/item_util.js b/chromium/chrome/browser/resources/md_extensions/item_util.js index aaefd16eb47..5f4aac7e18c 100644 --- a/chromium/chrome/browser/resources/md_extensions/item_util.js +++ b/chromium/chrome/browser/resources/md_extensions/item_util.js @@ -24,6 +24,7 @@ cr.define('extensions', function() { case chrome.developerPrivate.ExtensionState.ENABLED: case chrome.developerPrivate.ExtensionState.TERMINATED: return true; + case chrome.developerPrivate.ExtensionState.BLACKLISTED: case chrome.developerPrivate.ExtensionState.DISABLED: return false; } diff --git a/chromium/chrome/browser/resources/md_extensions/keyboard_shortcuts.html b/chromium/chrome/browser/resources/md_extensions/keyboard_shortcuts.html index cb443c889fc..5bdefc60481 100644 --- a/chromium/chrome/browser/resources/md_extensions/keyboard_shortcuts.html +++ b/chromium/chrome/browser/resources/md_extensions/keyboard_shortcuts.html @@ -74,15 +74,15 @@ .icon { -webkit-margin-end: 20px; - height: 16px; - width: 16px; + height: 20px; + width: 20px; } .card-controls { /* We line up the controls with the name, which is after the - 20px left padding + 16px icon + 20px margin on the icon. */ + 20px left padding + 20px icon + 20px margin on the icon. */ -webkit-margin-end: 20px; - -webkit-margin-start: 56px; + -webkit-margin-start: 60px; } </style> <div id="container"> diff --git a/chromium/chrome/browser/resources/md_extensions/kiosk_dialog.html b/chromium/chrome/browser/resources/md_extensions/kiosk_dialog.html index 2709a38694b..fa86c94ec05 100644 --- a/chromium/chrome/browser/resources/md_extensions/kiosk_dialog.html +++ b/chromium/chrome/browser/resources/md_extensions/kiosk_dialog.html @@ -95,13 +95,13 @@ </div> <div class="item-controls"> <paper-button hidden="[[!canEditAutoLaunch_]]" - on-tap="onAutoLaunchButtonTap_"> + on-click="onAutoLaunchButtonTap_"> [[getAutoLaunchButtonLabel_(item.autoLaunch, '$i18nPolymer{kioskDisableAutoLaunch}', '$i18nPolymer{kioskEnableAutoLaunch}')]] </paper-button> <button is="paper-icon-button-light" class="icon-delete-gray" - on-tap="onDeleteAppTap_"></button> + on-click="onDeleteAppTap_"></button> </div> </div> </template> @@ -114,7 +114,7 @@ '$i18nPolymer{kioskInvalidApp}', errorAppId_)]]" on-keydown="clearInputInvalid_"> </paper-input> - <paper-button id="add-button" on-tap="onAddAppTap_" + <paper-button id="add-button" on-click="onAddAppTap_" disabled="[[!addAppInput_]]"> $i18n{add} </paper-button> @@ -126,7 +126,7 @@ </paper-checkbox> </div> <div slot="button-container"> - <paper-button class="action-button" on-tap="onDoneTap_"> + <paper-button class="action-button" on-click="onDoneTap_"> $i18n{done} </paper-button> </div> @@ -136,10 +136,12 @@ <div slot="title">$i18n{kioskDisableBailoutWarningTitle}</div> <div slot="body">$i18n{kioskDisableBailoutWarningBody}</div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onBailoutDialogCancelTap_"> + <paper-button class="cancel-button" + on-click="onBailoutDialogCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onBailoutDialogConfirmTap_"> + <paper-button class="action-button" + on-click="onBailoutDialogConfirmTap_"> $i18n{confirm} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/md_extensions/load_error.html b/chromium/chrome/browser/resources/md_extensions/load_error.html index e5de72a0497..10080e154d5 100644 --- a/chromium/chrome/browser/resources/md_extensions/load_error.html +++ b/chromium/chrome/browser/resources/md_extensions/load_error.html @@ -40,11 +40,11 @@ </div> <div slot="button-container"> <paper-spinner-lite active="[[retrying_]]"></paper-spinner-lite> - <paper-button class="cancel-button" on-tap="close"> + <paper-button class="cancel-button" on-click="close"> $i18n{cancel} </paper-button> <paper-button class="action-button" disabled="[[retrying_]]" - on-tap="onRetryTap_"> + on-click="onRetryTap_"> $i18n{loadErrorRetry} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/md_extensions/options_dialog.html b/chromium/chrome/browser/resources/md_extensions/options_dialog.html index 3c959b13644..5c5abc51db6 100644 --- a/chromium/chrome/browser/resources/md_extensions/options_dialog.html +++ b/chromium/chrome/browser/resources/md_extensions/options_dialog.html @@ -23,12 +23,13 @@ ExtensionOptions { display: block; min-width: 300px; + overflow: hidden; } dialog { + --scroll-border: 0; width: fit-content; --cr-dialog-body: { - overflow: hidden; padding: 0; }; diff --git a/chromium/chrome/browser/resources/md_extensions/options_dialog.js b/chromium/chrome/browser/resources/md_extensions/options_dialog.js index adffadd9da2..d9ecf142ee3 100644 --- a/chromium/chrome/browser/resources/md_extensions/options_dialog.js +++ b/chromium/chrome/browser/resources/md_extensions/options_dialog.js @@ -5,6 +5,25 @@ cr.define('extensions', function() { 'use strict'; + /** + * @return {!Promise} A signal that the document is ready. Need to wait for + * this, otherwise the custom ExtensionOptions element might not have been + * registered yet. + */ + function whenDocumentReady() { + if (document.readyState == 'complete') + return Promise.resolve(); + + return new Promise(function(resolve) { + document.addEventListener('readystatechange', function f() { + if (document.readyState == 'complete') { + document.removeEventListener('readystatechange', f); + resolve(); + } + }); + }); + } + const OptionsDialog = Polymer({ is: 'extensions-options-dialog', @@ -25,21 +44,23 @@ cr.define('extensions', function() { /** @param {chrome.developerPrivate.ExtensionInfo} data */ show: function(data) { this.data_ = data; - if (!this.extensionOptions_) - this.extensionOptions_ = document.createElement('ExtensionOptions'); - this.extensionOptions_.extension = this.data_.id; - this.extensionOptions_.onclose = this.close.bind(this); + whenDocumentReady().then(() => { + if (!this.extensionOptions_) + this.extensionOptions_ = document.createElement('ExtensionOptions'); + this.extensionOptions_.extension = this.data_.id; + this.extensionOptions_.onclose = this.close.bind(this); - const onSizeChanged = e => { - this.extensionOptions_.style.height = e.height + 'px'; - this.extensionOptions_.style.width = e.width + 'px'; + const onSizeChanged = e => { + this.extensionOptions_.style.height = e.height + 'px'; + this.extensionOptions_.style.width = e.width + 'px'; - if (!this.$$('dialog').open) - this.$$('dialog').showModal(); - }; + if (!this.$$('dialog').open) + this.$$('dialog').showModal(); + }; - this.extensionOptions_.onpreferredsizechanged = onSizeChanged; - this.$.body.appendChild(this.extensionOptions_); + this.extensionOptions_.onpreferredsizechanged = onSizeChanged; + this.$.body.appendChild(this.extensionOptions_); + }); }, close: function() { diff --git a/chromium/chrome/browser/resources/md_extensions/pack_dialog.html b/chromium/chrome/browser/resources/md_extensions/pack_dialog.html index 1ee1ae2c232..e984b85658b 100644 --- a/chromium/chrome/browser/resources/md_extensions/pack_dialog.html +++ b/chromium/chrome/browser/resources/md_extensions/pack_dialog.html @@ -36,7 +36,7 @@ <paper-input id="root-dir" label="$i18n{packDialogExtensionRoot}" always-float-label value="{{packDirectory_}}"> </paper-input> - <paper-button id="root-dir-browse" on-tap="onRootBrowse_"> + <paper-button id="root-dir-browse" on-click="onRootBrowse_"> $i18n{packDialogBrowse} </paper-button> </div> @@ -44,16 +44,16 @@ <paper-input id="key-file" label="$i18n{packDialogKeyFile}" always-float-label value="{{keyFile_}}"> </paper-input> - <paper-button id="key-file-browse" on-tap="onKeyBrowse_"> + <paper-button id="key-file-browse" on-click="onKeyBrowse_"> $i18n{packDialogBrowse} </paper-button> </div> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_"> + <paper-button class="cancel-button" on-click="onCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onConfirmTap_" + <paper-button class="action-button" on-click="onConfirmTap_" disabled="[[!packDirectory_]]"> $i18n{packDialogConfirm} </paper-button> diff --git a/chromium/chrome/browser/resources/md_extensions/pack_dialog_alert.html b/chromium/chrome/browser/resources/md_extensions/pack_dialog_alert.html index 68bc231c581..ebf69a90b1b 100644 --- a/chromium/chrome/browser/resources/md_extensions/pack_dialog_alert.html +++ b/chromium/chrome/browser/resources/md_extensions/pack_dialog_alert.html @@ -22,10 +22,10 @@ <div class="body" slot="body">[[model.message]]</div> <div class="button-container" slot="button-container"> <paper-button class$="[[getCancelButtonClass_(confirmLabel_)]]" - on-tap="onCancelTap_" hidden="[[!cancelLabel_]]"> + on-click="onCancelTap_" hidden="[[!cancelLabel_]]"> [[cancelLabel_]] </paper-button> - <paper-button class="action-button" on-tap="onConfirmTap_" + <paper-button class="action-button" on-click="onConfirmTap_" hidden="[[!confirmLabel_]]"> [[confirmLabel_]] </paper-button> diff --git a/chromium/chrome/browser/resources/md_extensions/shortcut_input.html b/chromium/chrome/browser/resources/md_extensions/shortcut_input.html index ad5c9bb03b9..94b9d0d31f0 100644 --- a/chromium/chrome/browser/resources/md_extensions/shortcut_input.html +++ b/chromium/chrome/browser/resources/md_extensions/shortcut_input.html @@ -46,7 +46,7 @@ no-label-float> </paper-input> <button id="clear" is="paper-icon-button-light" - class="icon-clear no-overlap" on-tap="onClearTap_" + class="icon-clear no-overlap" on-click="onClearTap_" hidden$="[[computeClearHidden_(capturing_, shortcut)]]"> </button> </div> diff --git a/chromium/chrome/browser/resources/md_extensions/sidebar.html b/chromium/chrome/browser/resources/md_extensions/sidebar.html index a8857075a54..5e89ab45503 100644 --- a/chromium/chrome/browser/resources/md_extensions/sidebar.html +++ b/chromium/chrome/browser/resources/md_extensions/sidebar.html @@ -62,12 +62,12 @@ <iron-selector id="sectionMenu"> <!-- Values for "data-path" attribute must match the "Page" enum. --> <a class="section-item" id="sections-extensions" href="/" - on-tap="onLinkTap_" data-path="items-list"> + on-click="onLinkTap_" data-path="items-list"> $i18n{sidebarExtensions} <paper-ripple></paper-ripple> </a> <a class="section-item" id="sections-shortcuts" href="/shortcuts" - on-tap="onLinkTap_" data-path="keyboard-shortcuts"> + on-click="onLinkTap_" data-path="keyboard-shortcuts"> $i18n{keyboardShortcuts} <paper-ripple></paper-ripple> </a> @@ -75,7 +75,7 @@ <div hidden="[[isSupervised]]"> <div class="separator"></div> <a class="section-item" id="more-extensions" target="_blank" - href="$i18n{getMoreExtensionsUrl}" on-tap="onMoreExtensionsTap_"> + href="$i18n{getMoreExtensionsUrl}" on-click="onMoreExtensionsTap_"> <span>$i18n{openChromeWebStore}</span> <div class="cr-icon icon-external"></div> <paper-ripple></paper-ripple> diff --git a/chromium/chrome/browser/resources/md_extensions/toolbar.html b/chromium/chrome/browser/resources/md_extensions/toolbar.html index b2d3b2f9af8..8e554185091 100644 --- a/chromium/chrome/browser/resources/md_extensions/toolbar.html +++ b/chromium/chrome/browser/resources/md_extensions/toolbar.html @@ -1,5 +1,6 @@ <link rel="import" href="chrome://resources/html/polymer.html"> +<link rel="import" href="chrome://resources/cr_elements/cr_toast/cr_toast.html"> <link rel="import" href="chrome://resources/cr_elements/cr_toggle/cr_toggle.html"> <link rel="import" href="chrome://resources/cr_elements/cr_toolbar/cr_toolbar.html"> <link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> @@ -40,7 +41,7 @@ -webkit-margin-end: 20px; } - #devDrawer[expanded] #button-strip { + #devDrawer[expanded] #buttonStrip { top: 0; } @@ -55,7 +56,7 @@ height: var(--button-row-height); } - #button-strip { + #buttonStrip { -webkit-margin-end: auto; -webkit-margin-start: auto; background: var(--toolbar-color); @@ -69,7 +70,7 @@ width: 100%; } - #button-strip paper-button { + #buttonStrip paper-button { -webkit-margin-end: 16px; color: white; /* Increase contrast compared to default values. */ @@ -87,6 +88,15 @@ .more-actions span { -webkit-margin-end: 16px; } + + cr-toast > div { + color: #fff; + display: flex; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } </style> <cr-toolbar page-name="$i18n{toolbarTitle}" search-prompt="$i18n{search}" @@ -101,7 +111,7 @@ icon-class="cr20:domain" icon-aria-label="$i18n{controlledSettingPolicy}"> </cr-tooltip-icon> - <cr-toggle id="dev-mode" on-change="onDevModeToggleChange_" + <cr-toggle id="devMode" on-change="onDevModeToggleChange_" disabled="[[shouldDisableDevMode_( devModeControlledByPolicy, isSupervised)]]" checked="[[inDevMode]]" aria-labelledby="devModeLabel"> @@ -109,20 +119,23 @@ </div> </cr-toolbar> <div id="devDrawer" expanded$="[[expanded_]]"> - <div id="button-strip"> - <paper-button hidden$="[[!canLoadUnpacked]]" id="load-unpacked" - on-tap="onLoadUnpackedTap_"> + <div id="buttonStrip"> + <paper-button hidden$="[[!canLoadUnpacked]]" id="loadUnpacked" + on-click="onLoadUnpackedTap_"> $i18n{toolbarLoadUnpacked} </paper-button> - <paper-button id="pack-extensions" on-tap="onPackTap_"> + <paper-button id="packExtensions" on-click="onPackTap_"> $i18n{toolbarPack} </paper-button> - <paper-button id="update-now" on-tap="onUpdateNowTap_" + <paper-button id="updateNow" on-click="onUpdateNowTap_" title="$i18n{toolbarUpdateNowTooltip}"> $i18n{toolbarUpdateNow} </paper-button> + <cr-toast duration="3000"> + <div>[[toastLabel_]]</div> + </cr-toast> <if expr="chromeos"> - <paper-button id="kiosk-extensions" on-tap="onKioskTap_" + <paper-button id="kioskExtensions" on-click="onKioskTap_" hidden$="[[!kioskEnabled]]"> $i18n{manageKioskApp} </paper-button> diff --git a/chromium/chrome/browser/resources/md_extensions/toolbar.js b/chromium/chrome/browser/resources/md_extensions/toolbar.js index 3205f8f4f21..b5d3a5cfd37 100644 --- a/chromium/chrome/browser/resources/md_extensions/toolbar.js +++ b/chromium/chrome/browser/resources/md_extensions/toolbar.js @@ -53,6 +53,12 @@ cr.define('extensions', function() { /** @private */ expanded_: Boolean, + + /** + * Text to display in update toast + * @private + */ + toastLabel_: String, }, behaviors: [I18nBehavior], @@ -130,12 +136,24 @@ cr.define('extensions', function() { /** @private */ onUpdateNowTap_: function() { - this.delegate.updateAllExtensions().then(() => { - Polymer.IronA11yAnnouncer.requestAvailability(); - this.fire('iron-announce', { - text: this.i18n('toolbarUpdateDone'), - }); - }); + const updateButton = this.$.updateNow; + assert(!updateButton.disabled); + updateButton.disabled = true; + const toastElement = this.$$('cr-toast'); + this.toastLabel_ = this.i18n('toolbarUpdatingToast'); + toastElement.show(); + this.delegate.updateAllExtensions() + .then(() => { + Polymer.IronA11yAnnouncer.requestAvailability(); + const doneText = this.i18n('toolbarUpdateDone'); + this.fire('iron-announce', {text: doneText}); + this.toastLabel_ = doneText; + toastElement.show(); + updateButton.disabled = false; + }) + .catch(function() { + updateButton.disabled = false; + }); }, }); diff --git a/chromium/chrome/browser/resources/md_history/app.html b/chromium/chrome/browser/resources/md_history/app.html index 5afaa5d63a1..a6bfb168ffd 100644 --- a/chromium/chrome/browser/resources/md_history/app.html +++ b/chromium/chrome/browser/resources/md_history/app.html @@ -49,7 +49,7 @@ } #drop-shadow { - @apply(--cr-container-shadow); + @apply --cr-container-shadow; } :host([toolbar-shadow_]) #drop-shadow { diff --git a/chromium/chrome/browser/resources/md_history/app.js b/chromium/chrome/browser/resources/md_history/app.js index c187aca222c..56f975fb23f 100644 --- a/chromium/chrome/browser/resources/md_history/app.js +++ b/chromium/chrome/browser/resources/md_history/app.js @@ -168,6 +168,13 @@ Polymer({ .getSelectedItemCount(); }, + selectOrUnselectAll: function() { + const list = /** @type {HistoryListElement} */ (this.$.history); + const toolbar = /** @type {HistoryToolbarElement} */ (this.$.toolbar); + list.selectOrUnselectAll(); + toolbar.count = list.getSelectedItemCount(); + }, + /** * Listens for call to cancel selection and loops through all items to set * checkbox to be unselected. @@ -218,6 +225,9 @@ Polymer({ case 'delete-command': e.canExecute = this.$.toolbar.count > 0; break; + case 'select-all-command': + e.canExecute = !this.$.toolbar.searchField.isSearchFocused(); + break; } }, @@ -230,6 +240,8 @@ Polymer({ this.focusToolbarSearchField(); else if (e.command.id == 'delete-command') this.deleteSelected(); + else if (e.command.id == 'select-all-command') + this.selectOrUnselectAll(); }, /** diff --git a/chromium/chrome/browser/resources/md_history/history.html b/chromium/chrome/browser/resources/md_history/history.html index cc221baf1bd..bdce86e6070 100644 --- a/chromium/chrome/browser/resources/md_history/history.html +++ b/chromium/chrome/browser/resources/md_history/history.html @@ -8,6 +8,11 @@ <link rel="stylesheet" href="chrome://resources/css/md_colors.css"> <style> + html { + /* Remove 300ms delay for 'click' event, when using touch interface. */ + touch-action: manipulation; + } + html, body { height: 100%; @@ -79,6 +84,12 @@ </if> <command id="delete-command" shortcut="Delete Backspace"> <command id="slash-command" shortcut="/"> +<if expr="is_macosx"> + <command id="select-all-command" shortcut="Meta|a"> +</if> +<if expr="not is_macosx"> + <command id="select-all-command" shortcut="Ctrl|a"> +</if> <link rel="import" href="chrome://resources/html/util.html"> <link rel="import" href="chrome://resources/html/load_time_data.html"> diff --git a/chromium/chrome/browser/resources/md_history/history_item.html b/chromium/chrome/browser/resources/md_history/history_item.html index 958032b7386..221f09b7af3 100644 --- a/chromium/chrome/browser/resources/md_history/history_item.html +++ b/chromium/chrome/browser/resources/md_history/history_item.html @@ -169,7 +169,7 @@ } #background { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background: #fff; bottom: 0; left: 0; diff --git a/chromium/chrome/browser/resources/md_history/history_item.js b/chromium/chrome/browser/resources/md_history/history_item.js index fa0dd0ec630..3329aa189f7 100644 --- a/chromium/chrome/browser/resources/md_history/history_item.js +++ b/chromium/chrome/browser/resources/md_history/history_item.js @@ -278,13 +278,13 @@ cr.define('md_history', function() { item: this.item, }); - // Stops the 'tap' event from closing the menu when it opens. + // Stops the 'click' event from closing the menu when it opens. e.stopPropagation(); }, /** - * Record metrics when a result is clicked. This is deliberately tied to - * on-click rather than on-tap, as on-click triggers from middle clicks. + * Record metrics when a result is clicked. + * @private */ onLinkClick_: function() { const browserService = md_history.BrowserService.getInstance(); diff --git a/chromium/chrome/browser/resources/md_history/history_list.html b/chromium/chrome/browser/resources/md_history/history_list.html index b27ce8d74e2..ed46d9fcda9 100644 --- a/chromium/chrome/browser/resources/md_history/history_list.html +++ b/chromium/chrome/browser/resources/md_history/history_list.html @@ -1,6 +1,7 @@ <link rel="import" href="chrome://resources/html/polymer.html"> <link rel="import" href="chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.html"> +<link rel="import" href="chrome://resources/cr_elements/paper_button_style_css.html"> <link rel="import" href="chrome://resources/cr_elements/shared_style_css.html"> <link rel="import" href="chrome://resources/polymer/v1_0/iron-a11y-announcer/iron-a11y-announcer.html"> <link rel="import" href="chrome://resources/polymer/v1_0/iron-list/iron-list.html"> @@ -14,7 +15,7 @@ <dom-module id="history-list"> <template> - <style include="shared-style cr-shared-style"> + <style include="shared-style cr-shared-style paper-button-style"> :host { box-sizing: border-box; display: block; @@ -22,7 +23,7 @@ } iron-list { - @apply(--card-sizing); + @apply --card-sizing; margin-top: var(--first-card-padding-top); } @@ -62,10 +63,10 @@ <div slot="title">$i18n{removeSelected}</div> <div slot="body">$i18n{deleteWarning}</div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onDialogCancelTap_"> + <paper-button class="cancel-button" on-click="onDialogCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onDialogConfirmTap_"> + <paper-button class="action-button" on-click="onDialogConfirmTap_"> $i18n{deleteConfirm} </paper-button> </div> @@ -74,15 +75,15 @@ <template is="cr-lazy-render" id="sharedMenu"> <dialog is="cr-action-menu"> - <button id="menuMoreButton" class="dropdown-item" + <button id="menuMoreButton" slot="item" class="dropdown-item" hidden="[[!canSearchMoreFromSite_( searchedTerm, actionMenuModel_.item.domain)]]" - on-tap="onMoreFromSiteTap_"> + on-click="onMoreFromSiteTap_"> $i18n{moreFromSite} </button> - <button id="menuRemoveButton" class="dropdown-item" + <button id="menuRemoveButton" slot="item" class="dropdown-item" hidden="[[!canDeleteHistory_]]" - on-tap="onRemoveFromHistoryTap_"> + on-click="onRemoveFromHistoryTap_"> $i18n{removeFromHistory} </button> </dialog> diff --git a/chromium/chrome/browser/resources/md_history/history_list.js b/chromium/chrome/browser/resources/md_history/history_list.js index 8b94ed48774..9cd061e5c60 100644 --- a/chromium/chrome/browser/resources/md_history/history_list.js +++ b/chromium/chrome/browser/resources/md_history/history_list.js @@ -138,6 +138,25 @@ Polymer({ this.fire('query-history', false); }, + selectOrUnselectAll: function() { + if (this.historyData_.length == this.getSelectedItemCount()) + this.unselectAllItems(); + else + this.selectAllItems(); + }, + + /** + * Select each item in |historyData|. + */ + selectAllItems: function() { + if (this.historyData_.length == this.getSelectedItemCount()) + return; + + this.historyData_.forEach((item, index) => { + this.changeSelection_(index, true); + }); + }, + /** * Deselect each item in |selectedItems|. */ @@ -205,6 +224,10 @@ Polymer({ .then((items) => { this.removeItemsByIndex_(Array.from(this.selectedItems)); this.fire('unselect-all'); + if (this.historyData_.length == 0) { + // Try reloading if nothing is rendered. + this.fire('query-history', false); + } }); }, diff --git a/chromium/chrome/browser/resources/md_history/side_bar.html b/chromium/chrome/browser/resources/md_history/side_bar.html index b49c3e2fe5c..49b11840b7b 100644 --- a/chromium/chrome/browser/resources/md_history/side_bar.html +++ b/chromium/chrome/browser/resources/md_history/side_bar.html @@ -107,7 +107,7 @@ <div class="separator"></div> <a id="clear-browsing-data" href="chrome://settings/clearBrowserData" - on-tap="onClearBrowsingDataTap_" + on-click="onClearBrowsingDataTap_" disabled$="[[guestSession_]]" tabindex$="[[computeClearBrowsingDataTabIndex_(guestSession_)]]"> $i18n{clearBrowsingData} diff --git a/chromium/chrome/browser/resources/md_history/synced_device_card.html b/chromium/chrome/browser/resources/md_history/synced_device_card.html index 708f60bcf3c..a9cc4154ac8 100644 --- a/chromium/chrome/browser/resources/md_history/synced_device_card.html +++ b/chromium/chrome/browser/resources/md_history/synced_device_card.html @@ -16,7 +16,7 @@ <template> <style include="shared-style"> :host { - @apply(--card-sizing); + @apply --card-sizing; -webkit-tap-highlight-color: transparent; display: block; padding-bottom: var(--card-padding-between); @@ -57,7 +57,7 @@ } #history-item-container { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background: #fff; border-radius: 2px; } diff --git a/chromium/chrome/browser/resources/md_history/synced_device_card.js b/chromium/chrome/browser/resources/md_history/synced_device_card.js index 3206eb236eb..bbd109a64a9 100644 --- a/chromium/chrome/browser/resources/md_history/synced_device_card.js +++ b/chromium/chrome/browser/resources/md_history/synced_device_card.js @@ -68,8 +68,7 @@ Polymer({ }, /** - * Open a single synced tab. Listens to 'click' rather than 'tap' - * to determine what modifier keys were pressed. + * Open a single synced tab. * @param {DomRepeatClickEvent} e * @private */ diff --git a/chromium/chrome/browser/resources/md_history/synced_device_manager.html b/chromium/chrome/browser/resources/md_history/synced_device_manager.html index 55e529c83e5..7b10c59e6cb 100644 --- a/chromium/chrome/browser/resources/md_history/synced_device_manager.html +++ b/chromium/chrome/browser/resources/md_history/synced_device_manager.html @@ -4,13 +4,14 @@ <link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> <link rel="import" href="chrome://resources/cr_elements/cr_action_menu/cr_action_menu.html"> <link rel="import" href="chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.html"> +<link rel="import" href="chrome://resources/cr_elements/paper_button_style_css.html"> <link rel="import" href="chrome://resources/cr_elements/shared_style_css.html"> <link rel="import" href="chrome://history/shared_style.html"> <link rel="import" href="chrome://history/synced_device_card.html"> <dom-module id="history-synced-device-manager"> <template> - <style include="shared-style cr-shared-style"> + <style include="shared-style cr-shared-style paper-button-style"> :host { display: block; overflow: auto; @@ -81,19 +82,19 @@ <div id="sign-in-promo">$i18n{signInPromo}</div> <div id="sign-in-promo-desc">$i18n{signInPromoDesc}</div> <paper-button id="sign-in-button" class="action-button" - on-tap="onSignInTap_"> + on-click="onSignInTap_"> $i18n{signInButton} </paper-button> </div> <template is="cr-lazy-render" id="menu"> <dialog is="cr-action-menu"> - <button id="menuOpenButton" class="dropdown-item" - on-tap="onOpenAllTap_"> + <button id="menuOpenButton" slot="item" class="dropdown-item" + on-click="onOpenAllTap_"> $i18n{openAll} </button> - <button id="menuDeleteButton" class="dropdown-item" - on-tap="onDeleteSessionTap_"> + <button id="menuDeleteButton" slot="item" class="dropdown-item" + on-click="onDeleteSessionTap_"> $i18n{deleteSession} </button> </dialog> diff --git a/chromium/chrome/browser/resources/md_user_manager/shared_styles.html b/chromium/chrome/browser/resources/md_user_manager/shared_styles.html index 7c9d1aa16ab..02eac8fb435 100644 --- a/chromium/chrome/browser/resources/md_user_manager/shared_styles.html +++ b/chromium/chrome/browser/resources/md_user_manager/shared_styles.html @@ -26,7 +26,7 @@ } paper-button.action { - @apply(--action-button); + @apply --action-button; } paper-button.action.primary { diff --git a/chromium/chrome/browser/resources/md_user_manager/user_manager.html b/chromium/chrome/browser/resources/md_user_manager/user_manager.html index 8dbe1d9def7..244e159517d 100644 --- a/chromium/chrome/browser/resources/md_user_manager/user_manager.html +++ b/chromium/chrome/browser/resources/md_user_manager/user_manager.html @@ -322,7 +322,7 @@ --paper-button-flat-keyboard-focus: { background: rgb(173, 50, 36); }; - @apply(--action-button); + @apply --action-button; } #user-manager-prompt-message { diff --git a/chromium/chrome/browser/resources/md_user_manager/user_manager_pages.html b/chromium/chrome/browser/resources/md_user_manager/user_manager_pages.html index 8a4acecdaf6..c2f8945b653 100644 --- a/chromium/chrome/browser/resources/md_user_manager/user_manager_pages.html +++ b/chromium/chrome/browser/resources/md_user_manager/user_manager_pages.html @@ -7,6 +7,7 @@ <link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/animations/fade-out-animation.html"> <link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/neon-animatable.html"> <link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/neon-animated-pages.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/web-animations.html"> <dom-module id="user-manager-pages"> <template> diff --git a/chromium/chrome/browser/resources/media/media_engagement.html b/chromium/chrome/browser/resources/media/media_engagement.html index fd374ce52d3..af4c2d8e1e2 100644 --- a/chromium/chrome/browser/resources/media/media_engagement.html +++ b/chromium/chrome/browser/resources/media/media_engagement.html @@ -18,6 +18,10 @@ font-size: 14px; } + button { + margin-bottom: 20px; + } + table { border-collapse: collapse; margin-bottom: 20px; @@ -50,7 +54,6 @@ .origin-cell { background-color: rgba(230, 230, 230, 0.5); - min-width: 500px; } .visits-count-cell, @@ -60,6 +63,7 @@ .significant-playbacks-count-cell { background-color: rgba(230, 230, 230, 0.5); text-align: right; + white-space: nowrap; } .base-score-input { @@ -96,6 +100,7 @@ </head> <body> <h1>Media Engagement</h1> + <button id="copy-all-to-clipboard">Copy all to clipboard</button> <table> <thead> <tr id="config-table-header"> @@ -110,6 +115,12 @@ <tbody id="config-table-body"> </tbody> </table> + <p> + <label> + <input id="show-no-playbacks" type="checkbox"> + Show sessions with no playbacks + </label> + </p> <table> <thead> <tr id="engagement-table-header"> @@ -117,10 +128,10 @@ Origin </th> <th sort-key="visits" sort-reverse> - Visits + Sessions </th> <th sort-key="mediaPlaybacks" sort-reverse> - Playbacks + Sessions with playback </th> <th sort-key="audiblePlaybacks" sort-reverse> Audible Playbacks* @@ -134,6 +145,9 @@ <th sort-key="isHigh" sort-reverse> Is High </th> + <th sort-key="highScoreChanges" sort-reverse> + Is High Changes + </th> <th sort-key="totalScore" class="sort-column" sort-reverse> Score </th> @@ -156,6 +170,7 @@ <td class="significant-playbacks-count-cell"></td> <td class="last-playback-time-cell"></td> <td class="is-high-cell"></td> + <td class="is-high-changes-cell"></td> <td class="total-score-cell"></td> <td class="engagement-bar-cell"> <div class="engagement-bar"></div> diff --git a/chromium/chrome/browser/resources/media/media_engagement.js b/chromium/chrome/browser/resources/media/media_engagement.js index 0ca0a3cd94c..a4a8af7841f 100644 --- a/chromium/chrome/browser/resources/media/media_engagement.js +++ b/chromium/chrome/browser/resources/media/media_engagement.js @@ -19,6 +19,7 @@ var engagementTableBody = null; var sortReverse = true; var sortKey = 'totalScore'; var configTableBody = null; +var showNoPlaybacks = false; /** * Creates a single row in the engagement table. @@ -37,8 +38,9 @@ function createRow(rowInfo) { new Date(rowInfo.lastMediaPlaybackTime).toISOString() : ''; td[6].textContent = rowInfo.isHigh ? 'Yes' : 'No'; - td[7].textContent = rowInfo.totalScore ? rowInfo.totalScore.toFixed(2) : '0'; - td[8].getElementsByClassName('engagement-bar')[0].style.width = + td[7].textContent = rowInfo.highScoreChanges; + td[8].textContent = rowInfo.totalScore ? rowInfo.totalScore.toFixed(2) : '0'; + td[9].getElementsByClassName('engagement-bar')[0].style.width = (rowInfo.totalScore * 50) + 'px'; return document.importNode(template.content, true); } @@ -77,7 +79,8 @@ function compareTableItem(sortKey, a, b) { if (sortKey == 'visits' || sortKey == 'mediaPlaybacks' || sortKey == 'lastMediaPlaybackTime' || sortKey == 'totalScore' || - sortKey == 'audiblePlaybacks' || sortKey == 'significantPlaybacks') { + sortKey == 'audiblePlaybacks' || sortKey == 'significantPlaybacks' || + sortKey == 'highScoreChanges') { return val1 - val2; } @@ -108,7 +111,7 @@ function renderConfigTable(config) { configTableBody.innerHTML = ''; configTableBody.appendChild( - createConfigRow('Min Visits', config.scoreMinVisits)); + createConfigRow('Min Sessions', config.scoreMinVisits)); configTableBody.appendChild( createConfigRow('Lower Threshold', config.highScoreLowerThreshold)); configTableBody.appendChild( @@ -121,7 +124,8 @@ function renderConfigTable(config) { function renderTable() { clearTable(); sortInfo(); - info.forEach(rowInfo => engagementTableBody.appendChild(createRow(rowInfo))); + info.filter(rowInfo => (showNoPlaybacks || rowInfo.mediaPlaybacks > 0)) + .forEach(rowInfo => engagementTableBody.appendChild(createRow(rowInfo))); } /** @@ -173,5 +177,27 @@ document.addEventListener('DOMContentLoaded', function() { renderTable(); }); } + + // Add handler to 'copy all to clipboard' button + var copyAllToClipboardButton = $('copy-all-to-clipboard'); + copyAllToClipboardButton.addEventListener('click', (e) => { + // Make sure nothing is selected + window.getSelection().removeAllRanges(); + + document.execCommand('selectAll'); + document.execCommand('copy'); + + // And deselect everything at the end. + window.getSelection().removeAllRanges(); + }); + + // Add handler to 'show no playbacks' checkbox + var showNoPlaybacksCheckbox = $('show-no-playbacks'); + showNoPlaybacksCheckbox.addEventListener('change', (e) => { + showNoPlaybacks = e.target.checked; + renderTable(); + }); + }); + })(); diff --git a/chromium/chrome/browser/resources/media/mei_preload/preloaded_data.pb b/chromium/chrome/browser/resources/media/mei_preload/preloaded_data.pb Binary files differindex 1302db9033b..1652cafc98a 100644 --- a/chromium/chrome/browser/resources/media/mei_preload/preloaded_data.pb +++ b/chromium/chrome/browser/resources/media/mei_preload/preloaded_data.pb diff --git a/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.css b/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.css index 7ae1c39ff5a..9cc54236cca 100644 --- a/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.css +++ b/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.css @@ -137,7 +137,7 @@ paper-item:hover { border: 0; } -paper-menu { +paper-listbox { color: rgba(0, 0, 0, 0.87); overflow-x: hidden; overflow-y: auto; diff --git a/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.html b/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.html index 9a9e85029f4..6b606df8ece 100644 --- a/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.html +++ b/chromium/chrome/browser/resources/media_router/elements/media_router_container/media_router_container.html @@ -3,7 +3,7 @@ <link rel="import" href="chrome://resources/polymer/v1_0/paper-checkbox/paper-checkbox.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-input/paper-input.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-item/paper-item.html"> -<link rel="import" href="chrome://resources/polymer/v1_0/paper-menu/paper-menu.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/paper-listbox/paper-listbox.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-spinner/paper-spinner-lite.html"> <link rel="import" href="../media_router_header/media_router_header.html"> <link rel="import" href="../route_details/route_details.html"> @@ -57,7 +57,7 @@ </media-router-header> <div id="content"> <template is="dom-if" if="[[!computeCastModeListHidden_(currentView_)]]"> - <paper-menu id="cast-mode-list" role="presentation" + <paper-listbox id="cast-mode-list" role="presentation" selectable="paper-item" selected="{{selectedCastModeMenuItem_}}"> <template is="dom-repeat" id="presentationCastModeList" items="[[computePresentationCastModeList_(castModeList)]]"> @@ -94,7 +94,7 @@ <div><span>[[item.description]]</span></div> </paper-item> </template> - </paper-menu> + </paper-listbox> </template> <template is="dom-if" if="[[!computeRouteDetailsHidden_(currentView_, issue)]]"> @@ -121,7 +121,7 @@ </div> <template is="dom-if" if="[[!computeSinkListHidden_(sinksToShow_)]]"> <div id="sink-list" hidden$="[[hideSinkListForAnimation_]]"> - <paper-menu id="sink-list-paper-menu" role="presentation"> + <paper-listbox id="sink-list-paper-menu" role="presentation"> <template is="dom-repeat" id="sinkList" items="[[sinksToShow_]]"> <paper-item on-tap="onSinkClick_"> <div class="sink-content"> @@ -158,7 +158,7 @@ </div> </paper-item> </template> - </paper-menu> + </paper-listbox> </div> </template> <template is="dom-if" if="[[searchEnabled_]]"> @@ -185,7 +185,8 @@ </div> <div id="search-results" hidden$="[[computeSearchResultsHidden_(searchResultsToShow_, isSearchListHidden_)]]"> - <paper-menu id="search-results-paper-menu" selected="0" role="presentation"> + <paper-listbox id="search-results-paper-menu" selected="0" + role="presentation"> <template is="dom-repeat" id="searchResults" items="[[searchResultsToShow_]]"> <paper-item class="search-item" on-tap="onSinkClick_"> @@ -226,7 +227,7 @@ </div> </paper-item> </template> - </paper-menu> + </paper-listbox> </div> </div> </template> diff --git a/chromium/chrome/browser/resources/media_router/elements/route_details/route_details.css b/chromium/chrome/browser/resources/media_router/elements/route_details/route_details.css index 92f8b1a7dd1..e5b7f3e72da 100644 --- a/chromium/chrome/browser/resources/media_router/elements/route_details/route_details.css +++ b/chromium/chrome/browser/resources/media_router/elements/route_details/route_details.css @@ -3,8 +3,8 @@ * found in the LICENSE file. */ #route-action-buttons { - @apply(--layout-horizontal); - @apply(--layout-end-justified); + @apply --layout-horizontal; + @apply --layout-end-justified; margin: 0 10px; padding: 0; white-space: nowrap; diff --git a/chromium/chrome/browser/resources/media_router/extension/BUILD.gn b/chromium/chrome/browser/resources/media_router/extension/BUILD.gn new file mode 100644 index 00000000000..3b002567f96 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/BUILD.gn @@ -0,0 +1,78 @@ +import("src/files.gni") + +group("all") { + deps = [ + ":media_router", + ] +} + +declare_args() { + # Determines whether JSCompiler should be used to typecheck + # JavaScript code for the Media Router extension. + enable_media_router_jscompile = false +} + +if (enable_media_router_jscompile) { + # Run JSCompiler for typechecking. + action("media_router_type_check") { + script = "//third_party/closure_compiler/compile2.py" + inputs = rebase_path(mr_module_files, ".", "src") + [ + "src/externs.js", + "src/mojo_externs.js", + ] + outputs = [ + target_gen_dir + "/$target_name.stamp", + ] + args = [ + "--out_file", + rebase_path(outputs[0], root_build_dir), + "--closure_args", + "dependency_mode=LOOSE", + "checks_only", + "--", + ] + rebase_path(inputs, root_build_dir) + } +} + +# Concatentate JS files to produce "module" JS files that can be +# loaded at runtime. This could be done by JSCompiler, but it depends +# on Java, and Java isn't always available. +action("media_router_modules") { + script = "concat_js_modules.py" + module_inputs = rebase_path(mr_module_files, ".", "src") + inputs = module_inputs + [ "prelude.js" ] + outputs = [] + foreach(module_name, mr_module_names) { + outputs += [ "${target_gen_dir}/${module_name}.js" ] + } + args = [ "--module-specs" ] + mr_module_specs + [ + "--prelude-file", + rebase_path("prelude.js", root_build_dir), + "--output-dir", + rebase_path(target_gen_dir + "/", root_build_dir), + "--", + ] + rebase_path(module_inputs, root_build_dir) +} + +# Produce the Media Router extension. At present, the extension isn't +# included in the Chromium distribution, but it can be sideloaded into +# Chromium for testing. +action("media_router") { + script = "assemble_extension.py" + inputs = [ + "manifest.yaml", + ] + outputs = [ + "$target_gen_dir/manifest.json", + ] + deps = [ + ":media_router_modules", + ] + if (enable_media_router_jscompile) { + deps += [ ":media_router_type_check" ] + } + args = [ + "--manifest_in=" + rebase_path("manifest.yaml", root_build_dir), + "--output_dir=" + rebase_path(target_gen_dir, root_build_dir), + ] +} diff --git a/chromium/chrome/browser/resources/media_router/extension/assemble_extension.py b/chromium/chrome/browser/resources/media_router/extension/assemble_extension.py new file mode 100644 index 00000000000..7b2df0a255b --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/assemble_extension.py @@ -0,0 +1,41 @@ +# Copyright 2018 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. + +"""This script generates manifest.json from manifest.yaml. + +In the future it may copy other non-JS files into the target +directory, hence the name of the script. +""" + +import argparse +import json +import re + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--manifest_in") + parser.add_argument("--output_dir") + args = parser.parse_args() + + # Load the input file, removing YAML-style comment lines to produce + # valid JSON. + json_data = '' + with open(args.manifest_in) as manifest_in: + for line in manifest_in: + if re.match("^ *#", line): + # Insert an empty line so line numbers aren't changed. + json_data += '\n' + else: + json_data += line + + # Verify that the result is valid JSON. + json.loads(json_data) + + # Dump the output to the requested location. + with open(args.output_dir + "/manifest.json", "w") as manifest_out: + manifest_out.writelines(json_data) + + +main() diff --git a/chromium/chrome/browser/resources/media_router/extension/concat_js_modules.py b/chromium/chrome/browser/resources/media_router/extension/concat_js_modules.py new file mode 100644 index 00000000000..cda9ca1dd47 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/concat_js_modules.py @@ -0,0 +1,87 @@ +# Copyright 2018 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. + +"""This script concatenates JS files into a set of output files. + +Modules are specified as a list of strings of the form +module=<name>:<num>[:...], where <name> is the name of the module and +<num> is the number of JS files that should be concatenates to make +the module file, and if a second : is present, everything after it is +ignored. (This strange format is used because it is compatible with +JSCompiler's --module flag.) +""" + +import argparse +import os.path +import re + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--module-specs", nargs="+", + help="List of module specifiations.") + parser.add_argument( + "--output-dir", + help="Directory where output files are written.") + parser.add_argument( + "--prelude-file", + help="A prelude file included in every output module.") + parser.add_argument( + "sources", nargs="+", + help="JS input files.") + args = parser.parse_args() + + # Read the prelude file, which contains code to be placed at the + # start of each module. + with open(args.prelude_file, "r") as prelude_in: + prelude = prelude_in.read() + + # Loop over all specified modules, and simulaneously traverse the + # list of source files using `source_index`. + source_index = 0 + for spec in args.module_specs: + # Split the module spec into a module name and a count of source + # files to include in the module. + pre, sep, post = spec.partition("module=") + assert pre == "" + assert sep + parts = post.split(":") + module_name = parts[0] + input_count = int(parts[1]) + + # Write the module file. + module_file = os.path.join(args.output_dir, module_name + ".js") + with open(module_file, "w") as module_out: + module_out.write(prelude) + + # Append as many input files as requested by the module spec. + for i in range(input_count): + source_file = args.sources[source_index] + source_index += 1 + module_name = None + with open(source_file, "r") as source_in: + module_out.write("goog.scope(() => {\n") + # Copy input line by line. + for line in source_in: + m = re.match(r"goog\.module\('([^']+)'\);\n", line) + if m: + # Handle goog.module statements specially. + module_name = m.group(1) + module_out.write("let exports = {};\n") + else: + # Typical case: just copy the line verbatim. + module_out.write(line) + if module_name: + # Put exported names into the global namespace. That's + # not now goog.module is supposed to work, but it's close + # enough. + module_out.write( + "__setGlobal('{}', exports);\n".format(module_name)); + module_out.write("});\n") + + # Check that every source file has been appended to a module. + if source_index != len(args.sources): + sys.exit("Too many input files.") + +main() diff --git a/chromium/chrome/browser/resources/media_router/extension/manifest.yaml b/chromium/chrome/browser/resources/media_router/extension/manifest.yaml new file mode 100644 index 00000000000..4eeb52af9d8 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/manifest.yaml @@ -0,0 +1,53 @@ +# Copyright 2018 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. + +# NOTE: Only a subset of YAML syntax is allowed in this file. Lines +# containing comments are stripped, and the remaining lines must be +# valid JSON. + +{ + "name": "Chromium Media Router", + "version": "0.1", + "manifest_version": 2, + "description": "Provider for discovery and services for mirroring of Chromium Media Router", + "minimum_chrome_version": "37", + + "permissions": [ + "alarms", + "declarativeWebRequest", + "desktopCapture", + "dial", + "http://*/*", + "mediaRouterPrivate", + "metricsPrivate", + "storage", + "settingsPrivate", + "tabCapture", + "tabs" + ], + + # Background script. + "background": { + "scripts": [ + "common.js", + "mirroring_common.js", + "background_script.js" + ], + "persistent": false + }, + + # Google Feedback requires: + # script-src: https://feedback.googleusercontent.com https://www.google.com + # https://www.gstatic.com/feedback + # child-src: https://www.google.com + # + # Webview elements are implemented as a custom <object> elements that loads + # dynamic plugin data. Without an "object-src 'self'" permission in the CSP, + # webview elements fail to attach to extension pages (crbug.com/509854). + "content_security_policy": "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' https://feedback.googleusercontent.com https://www.google.com https://www.gstatic.com; child-src https://www.google.com; connect-src 'self' http://*:* https://*:*; font-src https://fonts.gstatic.com; object-src 'self';", + + # Setting the public key fixes the extension id to: + # enhhojjnijigcajfphajepfemndkmdlo + "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+hlN5FB+tjCsBszmBIvIcD/djLLQm2zZfFygP4U4/o++ZM91EWtgII10LisoS47qT2TIOg4Un4+G57elZ9PjEIhcJfANqkYrD3t9dpEzMNr936TLB2u683B5qmbB68Nq1Eel7KVc+F0BqhBondDqhvDvGPEV0vBsbErJFlNH7SQIDAQAB" +} diff --git a/chromium/chrome/browser/resources/media_router/extension/prelude.js b/chromium/chrome/browser/resources/media_router/extension/prelude.js new file mode 100644 index 00000000000..6140042e26d --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/prelude.js @@ -0,0 +1,50 @@ +// 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. + +// Implementation of JSCompiler intrinsics for use when JSCompiler is +// not available. + +/** + * Sets the values of a global expression consisting of a + * dot-delimited list of identifiers. + */ +const __setGlobal = (name, value) => { + let parent = window; + const parts = name.split('.'); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i == parts.length - 1) { + parent[part] = value; + } else { + if (!parent[part]) { + parent[part] = {}; + } + parent = parent[part]; + } + } +}; + +const goog = { + provide(name) { + __setGlobal(name, {}); + }, + + require(name) { + let parent = window; + name.split('.').forEach(part => { + parent = parent[part]; + }); + return parent; + }, + + module: { + declareLegacyNamespace() {}, + }, + + forwardDeclare() {}, + + scope(body) { + body.call(window); + }, +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/background.js b/chromium/chrome/browser/resources/media_router/extension/src/background.js new file mode 100644 index 00000000000..e1b87123e7f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/background.js @@ -0,0 +1,9 @@ +// 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. + + +goog.require('mr.Init'); + + +mr.Init.init().then(undefined, err => window.close()); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/config.js b/chromium/chrome/browser/resources/media_router/extension/src/config.js new file mode 100644 index 00000000000..fc339414189 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/config.js @@ -0,0 +1,26 @@ +// 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. + +/** + * @fileoverview Configs. + */ + +goog.provide('mr.Config'); + + +/** + * Compiler flag used to enable debug/testing only components. The default + * value defined here is only used in open-source builds. + * @define {boolean} True if this extension was released through debug channel. + */ +mr.Config.isDebugChannel = true; + + +/** + * Compiler flag used to set logging level and other privacy sensitive config + * for public release. The default value defined here is only used in + * open-source builds. + * @define {boolean} True if this extension was released through public channel. + */ +mr.Config.isPublicChannel = false; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/event_listener.js b/chromium/chrome/browser/resources/media_router/extension/src/event_listener.js new file mode 100644 index 00000000000..8cef915b1a3 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/event_listener.js @@ -0,0 +1,188 @@ +// 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. + +/** + * @fileoverview Interface for registration of listeners of events that can + * wake the extension. All EventListeners must invoke + * |addOnStartup| in the first event loop in order to + * properly receive events that woke the extension. When adding a new + * EventListener, be sure to also add it to |mr.Init.addEventListeners_|. + */ + +goog.provide('mr.EventListener'); + +goog.require('mr.EventAnalytics'); +goog.require('mr.Module'); +goog.require('mr.PersistentData'); +goog.require('mr.PersistentDataManager'); + + + +/** + * Listens for an extension event conditionally, and forwards the event to a + * designated module. + * + * This is useful for event listeners that are not added when the extension + * initially starts up (mDNS listeners, for instance). When an event + * listener is ready to be added for the first time (and the module is ready + * to handle the event), |addListener()| should be called. By doing so, the + * event listener will be automatically added back at the top level the next + * time the extension wakes up from suspension, unless |removeListener()| is + * called. + * + * @implements {mr.PersistentData} + * @template EVENT + */ +mr.EventListener = class { + /** + * @param {!mr.EventAnalytics.Event} eventType The event type for this + * listener to record with analytics. + * @param {string} name Name of the handler for PersistentData. + * @param {mr.ModuleId} moduleId Name of the module handling the events. + * @param {!EVENT} eventHandler The event handler to listen to. + * @param {...*} listenerArgs Additional arguments when adding the listener, + * such as filters. + */ + constructor(eventType, name, moduleId, eventHandler, ...listenerArgs) { + /** @private @const {!mr.EventAnalytics.Event} */ + this.eventType_ = eventType; + + /** @private @const {string} */ + this.name_ = name; + + /** @private @const {mr.ModuleId} */ + this.moduleId_ = moduleId; + + /** @private @const {!EVENT} */ + this.eventHandler_ = eventHandler; + + /** @private @const {!Array<*>} */ + this.listenerArgs_ = listenerArgs; + + /** + * This field is stored as temporary data. Set to true if the listener was + * added, and will be added back the next time the extension wakes up via + * |addOnStartup()|. + * @private {boolean} + */ + this.hasListener_ = false; + + /** @private @const {?function(*)} */ + this.listener_ = (...args) => this.dispatchEvent_(...args); + } + + /** + * Adds back the event listener that was added before the last suspension. + * This method is called by mr.Init during the first event loop only. + */ + addOnStartup() { + mr.PersistentDataManager.register(this); + } + + /** + * Helper method to add the listener to the event. + * @private + */ + doAddListener_() { + this.eventHandler_.addListener(this.listener_, ...this.listenerArgs_); + } + + /** + * Adds event listener. No-ops if listener is already added. Event + * listeners will be added back automatically the next time extension wakes + * up, during |addOnStartup|. + */ + addListener() { + if (this.hasListener_) { + return; + } + this.hasListener_ = true; + this.doAddListener_(); + } + + /** + * Removes event listener. The next time extension wakes up, the event + * listener will not be added back during |addOnStartup|. + */ + removeListener() { + if (!this.hasListener_) { + return; + } + this.eventHandler_.removeListener(this.listener_); + this.hasListener_ = false; + } + + /** + * Implementations may override this method validate an incoming event before + * it is forwarded to the designated module. + * @param {...*} args + * @return {boolean} true if the event can be forwarded to the module. + */ + validateEvent(...args) { + return true; + } + + /** + * The entry point for incoming events. First the event will be validated. + * After that, the event will be forwarded to the module, which is loaded if + * necessary. Since asynchronous event handling is required, a value + * representing asynchronous handling will be returned synchronously, as + * required by the event. + * @param {...*} args Parameters for the incoming event. + * @return {*} false if the event is invalid. Otherwise, a value that + * represents that event will be handled asynchronously will be returned. + * @private + */ + dispatchEvent_(...args) { + mr.EventAnalytics.recordEvent(this.eventType_); + if (!this.validateEvent(...args)) { + return false; + } + mr.Module.load(this.moduleId_) + .then(module => module.handleEvent(this.eventHandler_, ...args)); + return this.deferredReturnValue(); + } + + /** + * Returns |true| if the event listener is already registered. + * @return {boolean} + */ + isRegistered() { + return this.hasListener_; + } + + /** + * @override + */ + getStorageKey() { + return 'mr.EventListener.' + this.name_; + } + + /** + * @override + */ + getData() { + return [this.hasListener_]; + } + + /** + * @override + */ + loadSavedData() { + const hadListener = + /** @type {boolean} */ ( + mr.PersistentDataManager.getTemporaryData(this)); + if (hadListener) { + this.addListener(); + } + } + + /** + * Implementations may override this method to provide a return value in the + * case the event is not handled synchronously. The default value is + * undefined. + * @return {*} + */ + deferredReturnValue() {} +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/event_listener_test.js b/chromium/chrome/browser/resources/media_router/extension/src/event_listener_test.js new file mode 100644 index 00000000000..a64895f5318 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/event_listener_test.js @@ -0,0 +1,147 @@ +// 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. + +goog.setTestOnly('event_listener_test'); + +goog.require('mr.EventAnalytics'); +goog.require('mr.EventListener'); +goog.require('mr.Module'); +goog.require('mr.PersistentDataManager'); +goog.require('mr.PromiseResolver'); +goog.require('mr.UnitTestUtils'); + + +describe('Tests event listeners', function() { + let mockEvent; + + beforeEach(function() { + mr.UnitTestUtils.mockChromeApi(); + mockEvent = jasmine.createSpyObj( + 'mockEvent', ['addListener', 'hasListener', 'removeListener']); + }); + + afterEach(function() { + mr.Module.clearForTest(); + mr.PersistentDataManager.clear(); + mr.UnitTestUtils.restoreChromeApi(); + }); + + it('EventListener addListener no listener args', function() { + const listener = new mr.EventListener( + mr.EventAnalytics.Event.DIAL_ON_ERROR, 'MockEventListener', + 'SomeModule', mockEvent); + listener.addListener(); + expect(mockEvent.addListener).toHaveBeenCalled(); + expect(listener.isRegistered()).toBe(true); + + listener.removeListener(); + expect(mockEvent.removeListener).toHaveBeenCalled(); + expect(listener.isRegistered()).toBe(false); + }); + + it('EventListener addListener with listener args', function() { + const listenerArgs = ['foo', 1]; + const listener = new mr.EventListener( + mr.EventAnalytics.Event.DIAL_ON_ERROR, 'MockEventListener', + 'SomeModule', mockEvent, ...listenerArgs); + listener.addListener(); + expect(mockEvent.addListener) + .toHaveBeenCalledWith(jasmine.any(Function), ...listenerArgs); + expect(listener.isRegistered()).toBe(true); + + listener.removeListener(); + expect(mockEvent.removeListener).toHaveBeenCalled(); + expect(listener.isRegistered()).toBe(false); + }); + + it('EventListener addOnStartup no prior register', function() { + const listener = new mr.EventListener( + mr.EventAnalytics.Event.DIAL_ON_ERROR, 'MockEventListener', + 'SomeModule', mockEvent); + listener.addOnStartup(); + expect(mockEvent.addListener).not.toHaveBeenCalled(); + }); + + it('EventListener addOnStartup registered before', function() { + const listener = new mr.EventListener( + mr.EventAnalytics.Event.DIAL_ON_ERROR, 'MockEventListener', + 'SomeModule', mockEvent); + listener.addOnStartup(); + expect(mockEvent.addListener.calls.count()).toBe(0); + listener.addListener(); + expect(mockEvent.addListener.calls.count()).toBe(1); + + mr.PersistentDataManager.suspendForTest(); + + const listener2 = new mr.EventListener( + mr.EventAnalytics.Event.DIAL_ON_ERROR, 'MockEventListener', + 'SomeModule', mockEvent); + // Registers with mr.PersistentDataManager again. It should see that it was + // previously registered before suspend, and re-adds the listener + // auatomatically. + listener2.addOnStartup(); + expect(mockEvent.addListener.calls.count()).toBe(2); + }); + + it('EventListener rejected invalid event', function() { + let savedListener = null; + const listener = new mr.EventListener( + mr.EventAnalytics.Event.DIAL_ON_ERROR, 'MockEventListener', + 'SomeModule', mockEvent); + mockEvent.addListener.and.callFake(listener => { + savedListener = listener; + }); + listener.addListener(); + expect(mockEvent.addListener).toHaveBeenCalled(); + expect(savedListener).not.toBeNull(); + spyOn(listener, 'validateEvent').and.returnValue(false); + + // Module won't be loaded since event did not pass validation. + spyOn(mr.Module, 'load'); + let returnedValue = savedListener('foo', 1); + expect(returnedValue).toBe(false); + expect(mr.Module.load.calls.count()).toBe(0); + }); + + it('EventListener dispatch event', function(done) { + spyOn(mr.EventAnalytics, 'recordEvent'); + let savedListener = null; + const listener = new mr.EventListener( + mr.EventAnalytics.Event.DIAL_ON_ERROR, 'MockEventListener', + 'SomeModule', mockEvent); + spyOn(listener, 'deferredReturnValue').and.returnValue(123); + mockEvent.addListener.and.callFake(listener => { + savedListener = listener; + }); + listener.addListener(); + expect(mockEvent.addListener).toHaveBeenCalled(); + expect(savedListener).not.toBeNull(); + + // Module not ready yet; events are queued up, and deferred value is + // returned synchronously. + let resolver = new mr.PromiseResolver(); + spyOn(mr.Module, 'load').and.returnValue(resolver.promise); + let returnedValue = savedListener('foo', 1); + expect(returnedValue).toBe(123); + returnedValue = savedListener('bar', 2); + expect(returnedValue).toBe(123); + expect(mr.Module.load.calls.count()).toBe(2); + + expect(mr.EventAnalytics.recordEvent) + .toHaveBeenCalledWith(mr.EventAnalytics.Event.DIAL_ON_ERROR); + expect(mr.EventAnalytics.recordEvent.calls.count()).toEqual(2); + + const module = jasmine.createSpyObj('mockModule', ['handleEvent']); + module.handleEvent.and.callFake((e, arg1, arg2) => { + if (arg1 == 'bar') { + expect(module.handleEvent).toHaveBeenCalledWith(mockEvent, 'foo', 1); + expect(module.handleEvent).toHaveBeenCalledWith(mockEvent, 'bar', 2); + done(); + } + }); + + // Fake loading the module. + resolver.resolve(module); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/extension_selector.js b/chromium/chrome/browser/resources/media_router/extension/src/extension_selector.js new file mode 100644 index 00000000000..652a20d0aa6 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/extension_selector.js @@ -0,0 +1,47 @@ +// 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. + +/** + * @fileoverview Selector for picking an MR extension to use. + + + */ + +goog.provide('mr.ExtensionId'); +goog.provide('mr.ExtensionSelector'); + + +/** + * @enum {string} + */ +mr.ExtensionId = { + PUBLIC: 'pkedcjkdefgpdelpbcmbmeomcjbeemfm', + DEV: 'enhhojjnijigcajfphajepfemndkmdlo' +}; + + +/** + * @return {Promise} Resolves if this extension should start itself, + * rejects otherwise. + */ +mr.ExtensionSelector.shouldStart = function() { + return new Promise((resolve, reject) => { + switch (window.location.host) { + case mr.ExtensionId.DEV: + resolve(); + break; + case mr.ExtensionId.PUBLIC: + chrome.management.get(mr.ExtensionId.DEV, result => { + if (chrome.runtime.lastError || !result.enabled) { + resolve(); + } else { + reject(Error('Dev extension is enabled')); + } + }); + break; + default: + reject(Error('Unknown extension id')); + } + }); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/external_message_listener.js b/chromium/chrome/browser/resources/media_router/extension/src/external_message_listener.js new file mode 100644 index 00000000000..3f4e0800dfa --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/external_message_listener.js @@ -0,0 +1,80 @@ +// 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. + +/** + * @fileoverview Listener for messages received from external apps/extensions or + * web pages. + * + + */ + +goog.provide('mr.ExternalMessageListener'); + +goog.require('mr.EventAnalytics'); +goog.require('mr.EventListener'); +goog.require('mr.InternalMessageType'); +goog.require('mr.ModuleId'); + + +mr.ExternalMessageListener = class extends mr.EventListener { + constructor() { + super( + mr.EventAnalytics.Event.RUNTIME_ON_MESSAGE_EXTERNAL, + 'ExternalMessageListener', mr.ModuleId.PROVIDER_MANAGER, + chrome.runtime.onMessageExternal); + } + + /** + * @override + */ + validateEvent(message, sender, sendResponse) { + // Make sure all messages have a sender |id| and that the ID is whitelisted. + // If messages have a |tab| they are from a web page (most likely the Cast + // setup page). + if (!sender.id || + mr.ExternalMessageListener.WHITELIST_.indexOf(sender.id) == -1) { + return false; + } + + // Check if message type is valid. + return message.type == mr.InternalMessageType.START || + message.type == mr.InternalMessageType.STOP || + message.type == mr.InternalMessageType.SUBSCRIBE_LOG_DATA; + } + + /** + * @override + */ + deferredReturnValue() { + // Indicates the messaging channel should be kept open until + // sendResponse() is called. + return true; + } + + /** @return {!mr.ExternalMessageListener} */ + static get() { + if (!mr.ExternalMessageListener.listener_) { + mr.ExternalMessageListener.listener_ = new mr.ExternalMessageListener(); + } + return mr.ExternalMessageListener.listener_; + } +}; + + +/** @private {?mr.ExternalMessageListener} */ +mr.ExternalMessageListener.listener_ = null; + + +/** + * List of app ids which are allowed to use the command messages. + * These must also be included in the manifest 'externally_connectable' list. + * + * @private @const {!Array<string>} + */ +mr.ExternalMessageListener.WHITELIST_ = [ + // ghire kiosk app + 'idmofbkcelhplfjnmmdolenpigiiiecc', // prod + 'ggedfkijiiammpnbdadhllnehapomdge', // staging + 'njjegkblellcjnakomndbaloifhcoccg' // dev +]; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/external_message_listener_test.js b/chromium/chrome/browser/resources/media_router/extension/src/external_message_listener_test.js new file mode 100644 index 00000000000..e1f5480a27a --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/external_message_listener_test.js @@ -0,0 +1,67 @@ +// 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. + +goog.setTestOnly('external_message_listener_test'); + +goog.require('mr.ExternalMessageListener'); +goog.require('mr.InternalMessageType'); +goog.require('mr.UnitTestUtils'); + +describe('Tests mr.ExternalMessageListener', () => { + let listener; + + beforeEach(() => { + mr.UnitTestUtils.mockChromeApi(); + listener = new mr.ExternalMessageListener(); + }); + + afterEach(() => { + mr.UnitTestUtils.restoreChromeApi(); + }); + + it('rejects sender not in whitelist', () => { + const sender = {'id': 'invalid'}; + const callback = response => { + fail('should not have called back'); + }; + + expect(listener.validateEvent({}, sender, callback)).toBe(false); + }); + + it('invalid type returns empty and closes channel', () => { + const sender = {'id': 'njjegkblellcjnakomndbaloifhcoccg'}; + const callback = response => { + fail('should not have called back'); + }; + + expect(listener.validateEvent({}, sender, callback)).toBe(false); + }); + + it('valid start message', () => { + const sender = {'id': 'njjegkblellcjnakomndbaloifhcoccg'}; + const callback = response => { + fail('should not have called back'); + }; + const message = {'type': mr.InternalMessageType.START}; + expect(listener.validateEvent(message, sender, callback)).toBe(true); + }); + + it('valid stop message', () => { + const sender = {'id': 'njjegkblellcjnakomndbaloifhcoccg'}; + const callback = response => { + fail('should not have called back'); + }; + const message = {'type': mr.InternalMessageType.STOP}; + expect(listener.validateEvent(message, sender, callback)).toBe(true); + }); + + it('valid log subscription message', () => { + const sender = {'id': 'njjegkblellcjnakomndbaloifhcoccg'}; + const callback = response => { + fail('should not have called back'); + }; + const message = {'type': mr.InternalMessageType.SUBSCRIBE_LOG_DATA}; + expect(listener.validateEvent(message, sender, callback)).toBe(true); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/externs.js b/chromium/chrome/browser/resources/media_router/extension/src/externs.js new file mode 100644 index 00000000000..eac0a60d527 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/externs.js @@ -0,0 +1,917 @@ +// 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. + +// NOTE: Througout this file, we use constructors instead of @typedefs to +// declare object properties. This prevents the JSCompiler from renaming these +// properties. These can be converted to shorter @typedef declarations when the +// JSCompiler adds full support for @typedef in externs. + +////////////////////////////////////////////////////////////////////////////// +// Externs for WebRTC PeerConnection as in +// http://www.w3.org/TR/2012/WD-webrtc-20120821/ +////////////////////////////////////////////////////////////////////////////// + + +/** @type {string} */ +MediaStreamEvent.prototype.type; + + +/** @type {string} */ +RTCPeerConnection.prototype.iceConnectionState; + +////////////////////////////////////////////////////////////////////////////// +// Externs for processes API +// See: https://developer.chrome.com/extensions/processes +////////////////////////////////////////////////////////////////////////////// + + +/** + * @const + */ +chrome.processes = {}; + + +/** + * @param {number} tabId + * @param {Function} callback + */ +chrome.processes.getProcessIdForTab = function(tabId, callback) {}; + + +/** + * @const + */ +chrome.processes.onUpdated = {}; + + +/** + * @param {Function} callback + */ +chrome.processes.onUpdated.addListener = function(callback) {}; + + +/** + * @param {Function} callback + */ +chrome.processes.onUpdated.removeListener = function(callback) {}; + +////////////////////////////////////////////////////////////////////////////// +// Externs for Tab Capture API +// See: https://developer.chrome.com/extensions/tabCapture.html +////////////////////////////////////////////////////////////////////////////// + + +/** @const {*} */ +chrome.tabCapture = {}; + + +/** + * @param {MediaConstraints} constraints + * @param {function(MediaStream)} callback + */ +chrome.tabCapture.capture = function(constraints, callback) {}; + + +/** + * @param {string} startUrl + * @param {MediaConstraints} constraints + * @param {function(MediaStream)} callback + */ +chrome.tabCapture.captureOffscreenTab = function( + startUrl, constraints, callback) {}; + + +/** + * @type {ChromeEvent} + */ +chrome.tabCapture.onStatusChanged; + +////////////////////////////////////////////////////////////////////////////// +// Externs for Desktop Capture API +// See: https://developer.chrome.com/extensions/desktopCapture.html +////////////////////////////////////////////////////////////////////////////// + + +/** @const */ +chrome.desktopCapture = {}; + + +/** + * @param {Array<string>} sources + * @param {function(string)} callback + * @return {number} desktopMediaRequestId + */ +chrome.desktopCapture.chooseDesktopMedia = function(sources, callback) {}; + + +/** + * @param {number} desktopMediaRequestId + */ +chrome.desktopCapture.cancelChooseDesktopMedia = function( + desktopMediaRequestId) {}; + +////////////////////////////////////////////////////////////////////////////// +// Externs for chrome.dial API +// IDL: http://goo.gl/qmKqro +////////////////////////////////////////////////////////////////////////////// + + +/** @const {*} */ +chrome.dial = {}; + + + +/** @constructor */ +chrome.dial.DialDevice = function() {}; + + +/** @type {string} */ +chrome.dial.DialDevice.prototype.deviceLabel; + + +/** @type {string} */ +chrome.dial.DialDevice.prototype.deviceDescriptionUrl; + + +/** @type {number} */ +chrome.dial.DialDevice.prototype.configId; + + +/** @constructor */ +chrome.dial.DialDeviceDescription = function() {}; + + +/** @type {string} */ +chrome.dial.DialDeviceDescription.prototype.deviceLabel; + + +/** @type {string} */ +chrome.dial.DialDeviceDescription.prototype.appUrl; + + +/** @type {string} */ +chrome.dial.DialDeviceDescription.prototype.deviceDescription; + + + +/** @constructor */ +chrome.dial.DialError = function() {}; + + +/** @type {string} */ +chrome.dial.DialError.prototype.code; + + +/** + * @param {function(boolean)} callback The result (true if successful). + */ +chrome.dial.discoverNow = function(callback) {}; + +/** + * @param {string} deviceLabel + * @param {function(?chrome.dial.DialDeviceDescription)} callback + */ +chrome.dial.fetchDeviceDescription = function(deviceLabel, callback) {}; + + +/** @type {ChromeEvent} */ +chrome.dial.onDeviceList; + + +/** @type {ChromeEvent} */ +chrome.dial.onError; + + +////////////////////////////////////////////////////////////////////////////// +// Externs for the chrome.cast.channel API +// IDL: http://goo.gl/G1hmAI +////////////////////////////////////////////////////////////////////////////// + + +/** @const */ +chrome.cast = chrome.cast || {}; + + +/** @const */ +chrome.cast.channel = {}; + + + +/** @constructor */ +chrome.cast.channel.ChannelInfo = function() {}; + + +/** @type {number} */ +chrome.cast.channel.ChannelInfo.prototype.channelId; + + +/** @type {string} */ +chrome.cast.channel.ChannelInfo.prototype.readyState; + + +/** @type {?string} */ +chrome.cast.channel.ChannelInfo.prototype.errorState; + + +/** @type {?boolean} */ +chrome.cast.channel.ChannelInfo.prototype.keepAlive; + + +/** @type {?boolean} */ +chrome.cast.channel.ChannelInfo.prototype.audioOnly; + + +/** @type {!chrome.cast.channel.ConnectInfo} */ +chrome.cast.channel.ChannelInfo.prototype.connectInfo; + + + +/** @constructor */ +chrome.cast.channel.MessageInfo = function() {}; + + +/** @type {string} */ +chrome.cast.channel.MessageInfo.prototype.namespace_; + + +/** @type {*} */ +chrome.cast.channel.MessageInfo.prototype.data; + + +/** @type {string} */ +chrome.cast.channel.MessageInfo.prototype.sourceId; + + +/** @type {string} */ +chrome.cast.channel.MessageInfo.prototype.destinationId; + + + +/** @constructor */ +chrome.cast.channel.ConnectInfo = function() {}; + + +/** @type {string} */ +chrome.cast.channel.ConnectInfo.prototype.ipAddress; + + +/** @type {number} */ +chrome.cast.channel.ConnectInfo.prototype.port; + + +/** @type {string} */ +chrome.cast.channel.ConnectInfo.prototype.auth; + + +/** @type {number} */ +chrome.cast.channel.ConnectInfo.prototype.timeout; + + +/** @type {number} */ +chrome.cast.channel.ConnectInfo.prototype.livenessTimeout; + + +/** @type {number} */ +chrome.cast.channel.ConnectInfo.prototype.pingInterval; + + + +/** @constructor */ +chrome.cast.channel.ErrorInfo = function() {}; + + +/** @type {string} */ +chrome.cast.channel.ErrorInfo.prototype.errorState; + + +/** @type {?number} */ +chrome.cast.channel.ErrorInfo.prototype.eventType; + + +/** @type {?number} */ +chrome.cast.channel.ErrorInfo.prototype.challengeReplyErrorType; + + +/** @type {?number} */ +chrome.cast.channel.ErrorInfo.prototype.netReturnValue; + + +/** @type {?number} */ +chrome.cast.channel.ErrorInfo.prototype.nssErrorCode; + + +/** + * @param {!chrome.cast.channel.ConnectInfo} connectInfo + * @param {function(!chrome.cast.channel.ChannelInfo=)} callback + */ +chrome.cast.channel.open = function(connectInfo, callback) {}; + + +/** + * @param {!chrome.cast.channel.ChannelInfo} channel + * @param {!chrome.cast.channel.MessageInfo} message + * @param {function(!chrome.cast.channel.ChannelInfo=)} callback + */ +chrome.cast.channel.send = function(channel, message, callback) {}; + + +/** + * @param {!chrome.cast.channel.ChannelInfo} channel + * @param {function(!chrome.cast.channel.ChannelInfo=)} callback + */ +chrome.cast.channel.close = function(channel, callback) {}; + + + +/** @const */ +chrome.cast.channel.onMessage; + + +/** + * @param {!function(!chrome.cast.channel.ChannelInfo, + * !chrome.cast.channel.MessageInfo)} listener + */ +chrome.cast.channel.onMessage.addListener = function(listener) {}; + + +/** + * @param {!function(!chrome.cast.channel.ChannelInfo, + * !chrome.cast.channel.MessageInfo)} listener + */ +chrome.cast.channel.onMessage.removeListener = function(listener) {}; + + +/** @const */ +chrome.cast.channel.onError; + + +/** + * @param {!function(!chrome.cast.channel.ChannelInfo, + * (!chrome.cast.channel.ErrorInfo|undefined))} listener + */ +chrome.cast.channel.onError.addListener = function(listener) {}; + + +/** + * @param {!function(!chrome.cast.channel.ChannelInfo, + * (!chrome.cast.channel.ErrorInfo|undefined))} listener + */ +chrome.cast.channel.onError.removeListener = function(listener) {}; + + +/** @const */ +chrome.cast.media = {}; + + +/** + * @param {function(ChromeWindow)} callback + */ +chrome.browserAction.openPopup = function(callback) {}; + +////////////////////////////////////////////////////////////////////////////// +// Externs for the chrome.cast.streaming APIs +// See: http://goo.gl/yInHUU +////////////////////////////////////////////////////////////////////////////// + + +/** @const {*} */ +chrome.cast.streaming = {}; + + +/** @const {*} */ +chrome.cast.streaming.session = {}; + + +/** + * @param {?MediaStreamTrack} audio + * @param {?MediaStreamTrack} video + * @param {!function(?number, ?number, !number)} callback + */ +chrome.cast.streaming.session.create = function(audio, video, callback) {}; + + +/** @const {*} */ +chrome.cast.streaming.rtpStream = {}; + + + +/** @constructor */ +chrome.cast.streaming.rtpStream.CodecSpecificParams = function() {}; + + + +/** @constructor */ +chrome.cast.streaming.rtpStream.RtpPayloadParams = function() {}; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.maxLatency; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.minLatency; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.animatedLatency; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.payloadType; + + +/** @type {string} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.codecName; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.ssrc; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.feedbackSsrc; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.clockRate; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.minBitrate; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.maxBitrate; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.channels; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.maxFrameRate; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.width; + + +/** @type {number} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.height; + + +/** @type {string} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.aesKey; + + +/** @type {string} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.aesIvMask; + + +/** @type {Array.<chrome.cast.streaming.rtpStream.CodecSpecificParams>} */ +chrome.cast.streaming.rtpStream.RtpPayloadParams.prototype.codecSpecificParams; + + + +/** @constructor */ +chrome.cast.streaming.rtpStream.RtpParams = function() {}; + + +/** @type {chrome.cast.streaming.rtpStream.RtpPayloadParams} */ +chrome.cast.streaming.rtpStream.RtpParams.prototype.payload; + + +/** @type {Array.<string>} */ +chrome.cast.streaming.rtpStream.RtpParams.prototype.rtcpFeatures; + + +/** + * @param {!number} streamId + */ +chrome.cast.streaming.rtpStream.destroy = function(streamId) {}; + + +/** + * @param {!number} streamId + * @return {!Array.<!chrome.cast.streaming.rtpStream.RtpParams>} + */ +chrome.cast.streaming.rtpStream.getSupportedParams = function(streamId) { + return [new chrome.cast.streaming.rtpStream.RtpParams]; +}; + + +/** + * @param {!number} streamId + * @param {!chrome.cast.streaming.rtpStream.RtpParams} params + */ +chrome.cast.streaming.rtpStream.start = function(streamId, params) {}; + + +/** + * @param {!number} streamId + */ +chrome.cast.streaming.rtpStream.stop = function(streamId) {}; + + +/** + * @param {!number} streamId + * @param {!boolean} enable + */ +chrome.cast.streaming.rtpStream.toggleLogging = function(streamId, enable) {}; + + +/** + * @param {!number} streamId + * @param {!string} extraData + * @param {!function(ArrayBuffer)} callback + */ +chrome.cast.streaming.rtpStream.getRawEvents = function( + streamId, extraData, callback) {}; + + +/** + * @param {!number} streamId + * @param {!function(Object)} callback + */ +chrome.cast.streaming.rtpStream.getStats = function(streamId, callback) {}; + + +/** @const {*} */ +chrome.cast.streaming.rtpStream.onStarted = {}; + + +/** + * @param {!function(!number)} listener + */ +chrome.cast.streaming.rtpStream.onStarted.addListener = function(listener) {}; + + +/** + * @param {!function(!number)} listener + */ +chrome.cast.streaming.rtpStream.onStarted.removeListener = function(listener) { +}; + + +/** @const {*} */ +chrome.cast.streaming.rtpStream.onStopped = {}; + + +/** + * @param {!function(!number)} listener + */ +chrome.cast.streaming.rtpStream.onStopped.addListener = function(listener) {}; + + +/** + * @param {!function(!number)} listener + */ +chrome.cast.streaming.rtpStream.onStopped.removeListener = function(listener) { +}; + + +/** @const {*} */ +chrome.cast.streaming.rtpStream.onError = {}; + + +/** + * @param {!function(number, string)} listener + */ +chrome.cast.streaming.rtpStream.onError.addListener = function(listener) {}; + + +/** + * @param {!function(number, string)} listener + */ +chrome.cast.streaming.rtpStream.onError.removeListener = function(listener) {}; + + + +/** @constructor */ +chrome.cast.streaming.udpTransport.IPEndPoint = function() {}; + + +/** @type {string} */ +chrome.cast.streaming.udpTransport.IPEndPoint.prototype.address; + + +/** @type {number} */ +chrome.cast.streaming.udpTransport.IPEndPoint.prototype.port; + + +/** + * @param {!number} streamId + */ +chrome.cast.streaming.udpTransport.destroy = function(streamId) {}; + + +/** @const {*} */ +chrome.cast.streaming.udpTransport = {}; + + +/** + * @param {!number} transportId + * @param {!chrome.cast.streaming.udpTransport.IPEndPoint} ipEndPoint + */ +chrome.cast.streaming.udpTransport.setDestination = function( + transportId, ipEndPoint) {}; + + +/** + * @param {!number} transportId + * @param {!Object} options + */ +chrome.cast.streaming.udpTransport.setOptions = function(transportId, options) { +}; + + +/** @const {*} */ +chrome.mojoPrivate = {}; + + +/** + * @param {!string} moduleName + * @return {!Promise.<T>} + * @template T + */ +chrome.mojoPrivate.requireAsync = function(moduleName) { + return Promise.resolve(Object.create(null)); +}; + + +////////////////////////////////////////////////////////////////////////////// +// Externs for UMA analytics +////////////////////////////////////////////////////////////////////////////// + + +/** @const {*} */ +chrome.metricsPrivate = {}; + + +/** + * Records an elapsed time of no more than 10 seconds. The sample value is + * specified in milliseconds. + * @param {string} metricName + * @param {number} value + */ +chrome.metricsPrivate.recordTime = function(metricName, value) {}; + + +/** + * Records an elapsed time of no more than 3 minutes. The sample value is + * specified in milliseconds. + * @param {string} metricName + * @param {number} value + */ +chrome.metricsPrivate.recordMediumTime = function(metricName, value) {}; + + +/** + * Records an elapsed time of no more than 1 hour. The sample value is + * specified in milliseconds. + * @param {string} metricName + * @param {number} value + */ +chrome.metricsPrivate.recordLongTime = function(metricName, value) {}; + + +/** + * Records an action performed by the user. + * @param {string} name + */ +chrome.metricsPrivate.recordUserAction = function(name) {}; + + +/** + * Records a value than can range from 1 to 100. + * @param {string} metricName + * @param {number} count + */ +chrome.metricsPrivate.recordSmallCount = function(metricName, count) {}; + + +/** + * Returns true if the user opted in to sending crash reports. + * @param {!function(boolean)} callback + */ +chrome.metricsPrivate.getIsCrashReportingEnabled = function(callback) {}; + + +/** + * Describes the type of metric that is to be collected. + * @typedef {{ + * metricName: string, + * type: string, + * min: number, + * max: number, + * buckets: number + * }} + */ +var MetricType; + + +/** + * Adds a value to the given metric. + * @param {MetricType} metricType + * @param {number} value + */ +chrome.metricsPrivate.recordValue = function(metricType, value) {}; + + +////////////////////////////////////////////////////////////////////////////// +// Externs subset for settingsPrivate API. +// see chromium/src/third_party/closure_compiler/externs/settings_private.js +////////////////////////////////////////////////////////////////////////////// + + +/** @const {*} */ +chrome.settingsPrivate = {}; + + +/** + * @enum {string} + */ +chrome.settingsPrivate.PrefType = { + BOOLEAN: 'BOOLEAN', + NUMBER: 'NUMBER', + STRING: 'STRING', + URL: 'URL', + LIST: 'LIST', + DICTIONARY: 'DICTIONARY', +}; + + +/** + * @typedef {{ + * key: string, + * type: !chrome.settingsPrivate.PrefType, + * value: *, + * }} + */ +chrome.settingsPrivate.PrefObject; + + +/** + * @param {string} name + * @param {function(!chrome.settingsPrivate.PrefObject):void} callback + */ +chrome.settingsPrivate.getPref = function(name, callback) {}; + + +/** + * @const + */ +chrome.settingsPrivate.onPrefsChanged = {}; + + +/** + * @param {function(!Array<!Object>):void} callback + */ +chrome.settingsPrivate.onPrefsChanged.hasListener = function(callback) {}; + + +/** + * @param {function(!Array<!Object>):void} callback + */ +chrome.settingsPrivate.onPrefsChanged.addListener = function(callback) {}; + + +/** + * @param {function(!Array<!Object>):void} callback + */ +chrome.settingsPrivate.onPrefsChanged.removeListener = function(callback) {}; + + +////////////////////////////////////////////////////////////////////////////// +// Externs for Google Calendar v3 API as in +// developers.google.com/google-apps/calendar/v3/reference/events#resource +////////////////////////////////////////////////////////////////////////////// + + +/** @const */ +chrome.cast.calendar = {}; + + + +/** @constructor */ +chrome.cast.calendar.Calendar = function() {}; + + +/** @type {string} */ +chrome.cast.calendar.Calendar.prototype.id; + + + +/** @constructor */ +chrome.cast.calendar.Event = function() {}; + + +/** @type {string} */ +chrome.cast.calendar.Event.prototype.summary; + + +/** @type {string} */ +chrome.cast.calendar.Event.prototype.hangoutLink; + + +/** + * @typedef {{ + * date: string, + * dateTime: string + * }} + */ +chrome.cast.calendar.Event.prototype.start; + + +/** + * @typedef {{ + * date: string, + * dateTime: string + * }} + */ +chrome.cast.calendar.Event.prototype.end; + + +////////////////////////////////////////////////////////////////////////////// +// Externs for Google Hangouts v1 API +////////////////////////////////////////////////////////////////////////////// + + +/** @const */ +chrome.cast.hangout = {}; + + +/** + * @typedef {{ + * 'service': (string|undefined), + * 'value': (string|undefined) + * }} + */ +chrome.cast.hangout.ExternalKey; + + +/** + * @typedef {{ + * 'hangout_id': (string|undefined), + * 'participant_id': (string|undefined), + * 'user_id': (string|undefined), + * 'display_name': (string|undefined), + * 'role': (string|undefined), + * 'client_type': (string|undefined), + * 'participant_state': (string|undefined), + * 'joined': (boolean|undefined) + * }} + */ +chrome.cast.hangout.Participant; + + +/** + * @typedef {{ + * 'hangout_id': (string|undefined), + * 'type': (string|undefined), + * 'external_key': (chrome.cast.hangout.ExternalKey|undefined), + * 'company_title': (string|undefined), + * 'meeting_room_name': (string|undefined), + * 'meeting_domain': (string|undefined), + * "sharing_url": (string|undefined), + * }} + */ +chrome.cast.hangout.Hangout; + + + +/** + + * @see https://developer.chrome.com/extensions/tabs#event-onUpdated + * @constructor + */ +function TabChangeInfo() {} + + +/** @type {string} */ +TabChangeInfo.prototype.status; + + +/** @type {string} */ +TabChangeInfo.prototype.url; + + +/** @type {boolean} */ +TabChangeInfo.prototype.pinned; + + +/** @type {boolean} */ +TabChangeInfo.prototype.audible; + + +/** @type {string} */ +TabChangeInfo.prototype.favIconUrl; + +////////////////////////////////////////////////////////////////////////////// +// Externs for declarativeWebRequest (not used except for channel checking) +////////////////////////////////////////////////////////////////////////////// + + +/** @type {Object} */ +chrome.declarativeWebRequest; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/files.gni b/chromium/chrome/browser/resources/media_router/extension/src/files.gni new file mode 100644 index 00000000000..3770bbca1cd --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/files.gni @@ -0,0 +1,145 @@ +# Generated file. Do not modify! + +mr_module_names = [ + "common", + "mirroring_common", + "background_script", +] +mr_module_specs = [ + "module=common:8", + "module=mirroring_common:73:common", + "module=background_script:4:mirroring_common", +] +mr_module_files = [ + # common files + "config.js", + "utils/assertions.js", + "utils/logger.js", + "utils/analytics.js", + "utils/promise_resolver.js", + "providers/common/retry.js", + "utils/xhr_manager.js", + "internal_message.js", + + # mirroring_common files + "manager/route_message_port.js", + "interface_data/issue.js", + "manager/cancellable_promise.js", + "mirror_services/mirror_analytics.js", + "utils/platform_utils.js", + "mirror_services/mirror_config.js", + "mirror_services/stream_capture/capture_parameters.js", + "mirror_services/stream_capture/mirror_media_stream.js", + "module.js", + "utils/event_analytics.js", + "utils/media_source_utils.js", + "mirror_services/mirror_service.js", + "mirror_services/mirror_service_name.js", + "mirror_services/mirror_activity.js", + "utils/tab_utils.js", + "mirror_services/mirror_session.js", + "mirror_services/mirror_settings.js", + "webrtc/messages.js", + "webrtc/peer_connection_analytics.js", + "webrtc/peer_connection.js", + "interface_data/sink.js", + "persistent_data.js", + "utils/base64.js", + "utils/sha1.js", + "utils/string_utils.js", + "providers/common/sink_utils.js", + "providers/dial/sink_app_status.js", + "providers/dial/dial_sink.js", + "event_listener.js", + "utils/device_counts.js", + "utils/device_counts_provider.js", + "utils/promise_utils.js", + "providers/common/id_generator.js", + "interface_data/media_route_controller.js", + "utils/mojo_utils.js", + "manager/route_id.js", + "interface_data/route.js", + "manager/provider.js", + "providers/common/runtime_error_utils.js", + "interface_data/route_request_error.js", + "interface_data/sink_list.js", + "manager/presentation_enums.js", + "manager/sink_availability.js", + "providers/dial/dial_analytics.js", + "providers/dial/dial_provider_callbacks.js", + "utils/event_target.js", + "manager/provider_manager_callbacks.js", + "providers/common/net_utils.js", + "providers/common/xhr_utils.js", + "providers/dial/dial_client.js", + "providers/dial/dial_activity.js", + "providers/dial/dial_activity_records.js", + "providers/dial/dial_sink_discovery_service.js", + "providers/dial/dial_app_discovery_service.js", + "providers/dial/presentation_url.js", + "providers/dial/dial_provider.js", + "interface_data/sink_search_criteria.js", + "utils/fixed_size_queue.js", + "log_manager.js", + "utils/object_utils.js", + "presentation_services/presentation_session.js", + "presentation_services/cloud_webrtc/webrtc_presentation_session.js", + "external_message_listener.js", + "internal_message_listener.js", + "init_helper.js", + "interface_data/route_message.js", + "interface_data/mojo.js", + "manager/mr_event_senders/throttling_sender.js", + "manager/mr_event_senders/route_message_sender.js", + "manager/provider_events.js", + "manager/route_message_port_impl.js", + "utils/throttle.js", + "manager/provider_manager.js", + + # background_script files + "extension_selector.js", + "providers/test/test_provider.js", + "init.js", + "background.js", +] +mr_test_files = [ + "event_listener_test.js", + "external_message_listener_test.js", + "init_test.js", + "interface_data/media_route_controller_test.js", + "internal_message_listener_test.js", + "manager/mr_event_senders/route_message_sender_test.js", + "manager/mr_event_senders/throttling_sender_test.js", + "manager/provider_manager_test.js", + "manager/route_id_test.js", + "mirror_services/mirror_activity_test.js", + "mirror_services/mirror_analytics_test.js", + "mirror_services/mirror_session_test.js", + "mirror_services/mirror_settings_test.js", + "mirror_services/stream_capture/mirror_media_stream_test.js", + "module_test.js", + "persistent_data_test.js", + "providers/common/id_generator_test.js", + "providers/common/net_utils_test.js", + "providers/dial/dial_activity_records_test.js", + "providers/dial/dial_analytics_test.js", + "providers/dial/dial_app_discovery_service_test.js", + "providers/dial/dial_client_test.js", + "providers/dial/dial_provider_test.js", + "providers/dial/dial_sink_discovery_service_test.js", + "providers/dial/dial_sink_test.js", + "providers/dial/presentation_url_test.js", + "providers/test/test_provider_test.js", + "utils/analytics_test.js", + "utils/base64_test.js", + "utils/event_analytics_test.js", + "utils/fixed_size_queue_test.js", + "utils/logger_test.js", + "utils/media_source_utils_test.js", + "utils/mock_promise_test.js", + "utils/object_utils_test.js", + "utils/sha1_test.js", + "utils/throttle_test.js", + "utils/xhr_manager_test.js", + "webrtc/peer_connection_test.js", +] diff --git a/chromium/chrome/browser/resources/media_router/extension/src/init.js b/chromium/chrome/browser/resources/media_router/extension/src/init.js new file mode 100644 index 00000000000..6e6140e6c94 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/init.js @@ -0,0 +1,157 @@ +// 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. + +goog.provide('mr.Init'); + +goog.require('mr.Config'); +goog.require('mr.ExtensionSelector'); +goog.require('mr.InitHelper'); +goog.require('mr.LogManager'); +goog.require('mr.Logger'); +goog.require('mr.MediaRouterService'); +goog.require('mr.MediumTiming'); +goog.require('mr.PersistentDataManager'); +goog.require('mr.ProviderManager'); +goog.require('mr.TestProvider'); + + +/** @private {mr.Logger} */ +mr.Init.logger_ = mr.Logger.getInstance('mr.Init'); + + +/** @type {string} */ +mr.Init.FIRST_WAKE_DURATION = 'MediaRouter.Provider.FirstWakeDuration'; + + +/** @type {string} */ +mr.Init.WAKE_DURATION = 'MediaRouter.Provider.WakeDuration'; + + +/** @private {?mr.MediumTiming} */ +mr.Init.wakeTiming_; + + +/** @private {?mr.ProviderManager} */ +mr.Init.providerManager_; + + +/** + * @param {!mr.ProviderManager} providerManager + * @return {!Array.<!mr.Provider>} + * @private + */ +mr.Init.getProviders_ = function(providerManager) { + const providers = mr.InitHelper.getProviders(providerManager); + if (!mr.Config.isPublicChannel) { + providers.push(new mr.TestProvider(providerManager)); + } + return providers; +}; + + +/** + * @return {!Promise} + * @private + */ +mr.Init.initProviderManager_ = function() { + return mr.ExtensionSelector.shouldStart() + .then(mr.MediaRouterService.getInstance) + .then(result => { + if (!result['mrService']) { + throw Error('Failed to get MR service'); + } + const mrInstanceId = result['mrInstanceId']; + if (!mrInstanceId) { + throw Error('Failed to get MR instance ID.'); + } + mr.Init.logger_.info('MR instance ID: ' + mrInstanceId); + const mediaRouterService = + /** @type {!mr.MediaRouterService} */ (result['mrService']); + if (!mr.Init.providerManager_) { + throw Error('providerManager not initialized.'); + } + /** @type {!mr.ProviderManager} */ + const providerManager = mr.Init.providerManager_; + mediaRouterService.setHandlers(providerManager); + + if (mr.PersistentDataManager.isChromeReloaded(mrInstanceId)) { + mr.Init.wakeTiming_.setName(mr.Init.FIRST_WAKE_DURATION); + } + chrome.runtime.onSuspend.addListener( + mr.Init.wakeTiming_.end.bind(mr.Init.wakeTiming_)); + + mr.PersistentDataManager.initialize(mrInstanceId); + + mr.LogManager.getInstance().registerDataManager(); + + const providers = mr.Init.getProviders_(providerManager); + if (!mr.Config.isDebugChannel) { + // Log unhandled promise rejections for external channels, + // but leave them as thrown exceptions for internal. + window.addEventListener('unhandledrejection', event => { + let e = event.reason; + if (!e.stack) { + e = Error(e); + } + mr.Init.logger_.error( + 'Unhandled promise rejection.', + /** @type {Error} */ (e)); + }); + } + providerManager.initialize( + mediaRouterService, providers, result['mrConfig']); + }) + .then(undefined, error => { + mr.Init.logger_.warning(error.message); + throw error; + }); +}; + + +/** + * Exposed for testing. + + * @return {!Array<!mr.EventListener>} + * @private + */ +mr.Init.getAllListeners_ = function() { + return [ + ...mr.InitHelper.getListeners(), + ]; +}; + + +/** + * Registers all event listeners. + * @private + */ +mr.Init.addEventListeners_ = function() { + mr.Init.getAllListeners_().forEach( + eventListener => eventListener.addOnStartup()); + mr.InitHelper.addEventListeners(); + + // Listen for an event that always get invoked on browser startup. This is + // necessary because Media Router must know the extension ID in order to wake + // the extension up, and MR gets the ID when the event page activates for the + // first time. If the event page never activates, then MR will never be able + // to connect to it. + + chrome.runtime.onStartup.addListener(() => {}); +}; + + +/** + * @return {!Promise} + */ +mr.Init.init = function() { + mr.LogManager.getInstance().init(); + mr.Init.wakeTiming_ = new mr.MediumTiming(mr.Init.WAKE_DURATION); + + /** @type {!mr.ProviderManager} */ + const providerManager = new mr.ProviderManager(); + mr.Init.providerManager_ = providerManager; + const promise = mr.Init.initProviderManager_(); + mr.Init.addEventListeners_(); + return promise; +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/init_helper.js b/chromium/chrome/browser/resources/media_router/extension/src/init_helper.js new file mode 100644 index 00000000000..320629a8547 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/init_helper.js @@ -0,0 +1,61 @@ +/** + * @fileoverview Definitions that are specific to the open-source + * version of the extension. The functions here don't do anything + * useful, but they need to be here because they're called by code + * that's shared with the closed-source version. + */ +goog.module('mr.InitHelper'); +goog.module.declareLegacyNamespace(); + +const DialProvider = goog.require('mr.DialProvider'); +const EventListener = goog.require('mr.EventListener'); +const Provider = goog.require('mr.Provider'); +const ProviderManager = goog.forwardDeclare('mr.ProviderManager'); + + +/** + * @param {!ProviderManager} providerManager + * @return {!Array<!Provider>} + */ +function getProviders(providerManager) { + return [new DialProvider(providerManager)]; +} + + +/** + * @return {!Array<!EventListener>} + */ +function getListeners() { + return []; +} + + +/** + * @return {void} + */ +function addEventListeners() {} + + +/** + * @return {!Function} + */ +function getInternalMessageHandler() { + return () => {}; +} + + +/** + * @return {!Function} + */ +function getExternalMessageHandler() { + return () => {}; +} + + +exports = { + getProviders, + getListeners, + addEventListeners, + getInternalMessageHandler, + getExternalMessageHandler, +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/init_test.js b/chromium/chrome/browser/resources/media_router/extension/src/init_test.js new file mode 100644 index 00000000000..25ba7d36419 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/init_test.js @@ -0,0 +1,82 @@ +// 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. + +goog.setTestOnly('init_test'); + +goog.require('mr.Init'); +goog.require('mr.MockClock'); +goog.require('mr.Module'); +goog.require('mr.UnitTestUtils'); + + + +describe('Tests init', function() { + let mockClock; + let savedCallback; + + beforeEach(function() { + mr.Module.clearForTest(); + mockClock = new mr.MockClock(true); + mr.UnitTestUtils.mockChromeApi(); + + for (let listener of mr.Init.getAllListeners_()) { + spyOn(listener, 'addOnStartup').and.callThrough(); + } + + spyOn(mr.ExtensionSelector, 'shouldStart') + .and.returnValue(Promise.resolve()); + spyOn(mr.MediaRouterService, 'getInstance').and.returnValue({ + 'mrService': jasmine.createSpyObj( + 'mrService', ['setHandlers', 'onRouteMessagesReceived']), + 'mrInstanceId': 'mrInstanceId' + }); + + spyOn(mr.PersistentDataManager, 'initialize'); + spyOn(mr.PersistentDataManager, 'register'); + spyOn(mr.Init, 'getProviders_').and.returnValue([]); + + savedCallback = null; + chrome.runtime.onSuspend.addListener.and.callFake(callback => { + savedCallback = callback; + }); + }); + + afterEach(function() { + mockClock.uninstall(); + mr.UnitTestUtils.restoreChromeApi(); + }); + + it('records first wake duration after Chrome reload', function(done) { + spyOn(mr.PersistentDataManager, 'isChromeReloaded').and.returnValue(true); + mr.Init.init().then(() => { + expect(chrome.runtime.onSuspend.addListener).toHaveBeenCalled(); + expect(savedCallback).not.toBeNull(); + mockClock.tick(12345); + savedCallback(); + expect(chrome.metricsPrivate.recordMediumTime) + .toHaveBeenCalledWith(mr.Init.FIRST_WAKE_DURATION, 12345); + done(); + }); + }); + + it('records wake duration after Chrome reload', function(done) { + spyOn(mr.PersistentDataManager, 'isChromeReloaded').and.returnValue(false); + mr.Init.init().then(() => { + expect(chrome.runtime.onSuspend.addListener).toHaveBeenCalled(); + expect(savedCallback).not.toBeNull(); + mockClock.tick(54321); + savedCallback(); + expect(chrome.metricsPrivate.recordMediumTime) + .toHaveBeenCalledWith(mr.Init.WAKE_DURATION, 54321); + done(); + }); + }); + + it('Registers event listeners on bootstrap', function(done) { + mr.Init.init().then(done, done.fail); + for (let listener of mr.Init.getAllListeners_()) { + expect(listener.addOnStartup).toHaveBeenCalled(); + } + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/issue.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/issue.js new file mode 100644 index 00000000000..c0da74b3fd1 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/issue.js @@ -0,0 +1,150 @@ +// 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. + +/** + * @fileoverview The issue object shared between component extension + * and Chrome media router. + */ + +goog.provide('mr.Issue'); +goog.provide('mr.IssueAction'); +goog.provide('mr.IssueSeverity'); + +goog.require('mr.Assertions'); + +/** + * Note: keep synced with the issue severity supported by MR's issue manager. + * @enum {string} + * @export + */ +mr.IssueSeverity = { + FATAL: 'fatal', + WARNING: 'warning', + NOTIFICATION: 'notification' +}; + + +/** + * Note: keep synced with the issue actions supported by MR's issue manager. + * @enum {string} + * @export + */ +mr.IssueAction = { + DISMISS: 'dismiss', + LEARN_MORE: 'learn_more' +}; + +mr.Issue = class { + /** + * @param {string} title + * @param {mr.IssueSeverity} severity + */ + constructor(title, severity) { + /** + * @type {?string} + * @export + */ + this.routeId = null; + + /** + * @type {mr.IssueSeverity} + * @export + */ + this.severity = severity; + + /** + * When true, this issue takes the whole dialog. + * @type {boolean} + * @export + */ + this.isBlocking = this.severity == mr.IssueSeverity.FATAL ? true : false; + + /** + * Short description about the issue. Localized string. + * @type {string} + * @export + */ + this.title = title; + + /** + * Message about issue detail or how to handle issue. + * Messages should be suitable for end users to decide which actions to + * take. + * @type {?string} + * @export + */ + this.message = null; + + /** + * @type {mr.IssueAction} + * @export + */ + this.defaultAction = mr.IssueAction.DISMISS; + + /** + * @type {Array.<mr.IssueAction>} + * @export + */ + this.secondaryActions = null; + + /** + * Required if one action is LEARN_MORE. + * @type {?number} + * @export + */ + this.helpPageId = null; + } + + /** + * Sets the action to LEARN_MORE and sets the pageId that is required by the + * action for targeting. + * @param {number} pageId + * @return {!mr.Issue} This object. + */ + setDefaultActionLearnMore(pageId) { + mr.Assertions.assert(pageId > 0); + this.helpPageId = pageId; + this.defaultAction = mr.IssueAction.LEARN_MORE; + return this; + } + + /** + * @param {Array.<mr.IssueAction>} secondaryActions + * @return {!mr.Issue} This object. + */ + setSecondaryActions(secondaryActions) { + this.secondaryActions = secondaryActions; + return this; + } + + /** + * @param {string} message + * @return {!mr.Issue} This object. + */ + setMessage(message) { + this.message = message; + return this; + } + + /** + * @param {boolean} isBlocking + * @return {!mr.Issue} This object. + */ + setIsBlocking(isBlocking) { + if (!isBlocking && this.severity == mr.IssueSeverity.FATAL) { + throw Error('All FATAL issues must be blocking.'); + } + this.isBlocking = isBlocking; + return this; + } + + /** + * @param {string} routeId + * @return {!mr.Issue} This object. + */ + setRouteId(routeId) { + this.routeId = routeId; + return this; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/media_route_controller.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/media_route_controller.js new file mode 100644 index 00000000000..0b9494b4194 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/media_route_controller.js @@ -0,0 +1,166 @@ +goog.module('mr.MediaRouteController'); +goog.module.declareLegacyNamespace(); + +const Logger = goog.require('mr.Logger'); + + +/** + * Controls a media that is being routed. This base class defines the same set + * of APIs as the MediaController Mojo interface defined in + * media_controller.mojom in Chromium, and contains a MojoBinding to the + * browser (so that MediaController commands from the browser are routed here). + * MRPs may extend this class with additional commands. + */ +const MediaRouteController = class { + /** + * @param {string} name Name of the controller (e.g. HangoutsRouteController). + * @param {!mojo.InterfaceRequest} controllerRequest The Mojo interface + * request to be bound to this controller. + * @param {!mojo.MediaStatusObserverPtr} observer The Mojo pointer to the + * MediaStatusObserver. + */ + constructor(name, controllerRequest, observer) { + /** @protected @const {!Logger} */ + this.logger = Logger.getInstance('mr.MediaRouteController.' + name); + + /** + * The binding used to maintain a Mojo connection with the browser process. + * @private {?mojo.Binding} + */ + this.binding_ = + new mojo.Binding(mojo.MediaController, this, controllerRequest); + + /** + * The observer to send media status updates to. + * @private {?mojo.MediaStatusObserverPtr} + */ + this.observer_ = observer; + + /** + * @protected @const {!mojo.MediaStatus} + */ + this.currentMediaStatus = new mojo.MediaStatus({ + title: '', + description: '', + duration: new mojo.TimeDelta({microseconds: 0}), + current_time: new mojo.TimeDelta({microseconds: 0}) + }); + + /** + * @private {boolean} + */ + this.disposed_ = false; + + this.binding_.setConnectionErrorHandler( + this.onMojoConnectionError.bind(this)); + this.observer_.ptr.setConnectionErrorHandler( + this.onMojoConnectionError.bind(this)); + } + + /** + * Drops the reference to the binding. + * @protected + */ + onMojoConnectionError() { + this.dispose(); + this.onControllerInvalidated(); + } + + /** + * Closes the connection in the binding and the observer. There will be no + * more incoming or outgoing calls after this. + * @final + */ + dispose() { + if (this.disposed_) { + return; + } + this.disposed_ = true; + this.disposeInternal(); + if (this.binding_) { + this.binding_.close(); + this.binding_ = null; + } + if (this.observer_) { + this.observer_.ptr.reset(); + this.observer_ = null; + } + } + + /** + * Notifies the observer of media status update, if it exists. + * @protected + */ + notifyObserver() { + if (this.observer_) { + this.observer_.onMediaStatusUpdated(this.currentMediaStatus); + } + } + + /** + * Performs additional cleanup when the controller is being disposed. + * @protected + */ + disposeInternal() {} + + /** + * Performs final cleanup after the controller is invalidated by a Mojo + * connection error. By the time this is called, dispose() has already + * happened. This gives an opportunity for clients to drop their references + * to the controller. + * @protected + */ + onControllerInvalidated() {} + + // The following are methods for handling incoming media commands. The method + // names must match the ones defined in media_controller.mojom in Chromium + // and be exported. + + /** + * Plays the media. + * @export + */ + play() {} + + /** + * Pauses the media. + * @export + */ + pause() {} + + /** + * Mutes or unmutes the media. + * @param {boolean} mute + * @export + */ + setMute(mute) {} + + /** + * Sets the volume on the media. The given volume must be between 0 and 1. + * @param {number} volume + * @export + */ + setVolume(volume) {} + + /** + * Seeks to the given time. The given time must be non-negative and less than + * or equal to the duration of the media. + * @param {!mojo.TimeDelta} time + * @export + */ + seek(time) {} + + /** + * Binds the given request to an implementation that accepts Hangouts-specific + * commands. + * @param {!mojo.InterfaceRequest} hangoutsControllerRequest Interface request + * for Hangouts controller. + * @export + */ + connectHangoutsMediaRouteController(hangoutsControllerRequest) { + hangoutsControllerRequest.close(); + throw new Error('Not implemented'); + } +}; + +exports = MediaRouteController; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/media_route_controller_test.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/media_route_controller_test.js new file mode 100644 index 00000000000..b0fd150f7e3 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/media_route_controller_test.js @@ -0,0 +1,70 @@ +goog.module('mr.MediaRouteControllerTest'); +goog.setTestOnly('mr.MediaRouteControllerTest'); + +const MediaRouteController = goog.require('mr.MediaRouteController'); +const UnitTestUtils = goog.require('mr.UnitTestUtils'); + +describe('MediaRouteController test', () => { + let binding; + let observer; + + beforeEach(() => { + UnitTestUtils.mockMojoApi(); + binding = UnitTestUtils.createMojoBindingSpyObj(); + observer = UnitTestUtils.createMojoMediaStatusObserverSpyObj(); + spyOn(mojo, 'Binding').and.returnValue(binding); + }); + + function createController() { + const controllerRequest = {}; + const controller = + new MediaRouteController('TestController', controllerRequest, observer); + expect(mojo.Binding) + .toHaveBeenCalledWith( + mojo.MediaController, controller, controllerRequest); + expect(binding.setConnectionErrorHandler).toHaveBeenCalled(); + expect(observer.ptr.setConnectionErrorHandler).toHaveBeenCalled(); + return controller; + } + + it('Sets things up on construction', () => { + createController(); + }); + + it('Cleans up on Mojo connection error', () => { + const controller = createController(); + spyOn(controller, 'onControllerInvalidated').and.callThrough(); + const onMojoConnectionError = + binding.setConnectionErrorHandler.calls.argsFor(0)[0]; + onMojoConnectionError(); + expect(binding.close).toHaveBeenCalled(); + expect(observer.ptr.reset).toHaveBeenCalled(); + expect(controller.onControllerInvalidated.calls.count()).toBe(1); + }); + + it('Cleans up on calling dispose()', () => { + const controller = createController(); + spyOn(controller, 'disposeInternal').and.callThrough(); + controller.dispose(); + expect(binding.close).toHaveBeenCalled(); + expect(observer.ptr.reset).toHaveBeenCalled(); + expect(controller.disposeInternal.calls.count()).toBe(1); + // Calling dispose twice has no effect. + controller.dispose(); + expect(controller.disposeInternal.calls.count()).toBe(1); + }); + + it('Notifies observer', () => { + const controller = createController(); + controller.notifyObserver(); + expect(observer.onMediaStatusUpdated) + .toHaveBeenCalledWith(controller.currentMediaStatus); + }); + + it('Does not notify observer after cleanup', () => { + const controller = createController(); + controller.dispose(); + controller.notifyObserver(); + expect(observer.onMediaStatusUpdated).not.toHaveBeenCalled(); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/mojo.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/mojo.js new file mode 100644 index 00000000000..3cacd0478af --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/mojo.js @@ -0,0 +1,415 @@ +// 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. + +/** + * @fileoverview Closure definitions of Mojo service objects. + */ + +goog.provide('mr.MediaRouterRequestHandler'); +goog.provide('mr.MediaRouterService'); + +goog.require('mr.RouteMessage'); + + +/** + * @interface + * + * Don't convert this interface to an ES6 class! Doing so causes things to + * break in strange ways. + */ +mr.MediaRouterService = function() {}; + + + +/** + * @return {!Promise<!Object>} + */ +mr.MediaRouterService.getInstance = function() { + if (!chrome.mojoPrivate || !chrome.mojoPrivate.requireAsync) { + return Promise.reject(Error('No mojo service loaded')); + } + return new Promise((resolve, reject) => { + // requireAsync does not return failures by design, so there is no error + // handler here. + chrome.mojoPrivate.requireAsync('media_router_bindings').then(mr => { + const mediaRouter = /** @type {!mr.MediaRouterService} */ (mr); + // Import all definitions into mojo namespace. + + mojo = mediaRouter.getMojoExports && mediaRouter.getMojoExports(); + mediaRouter.start().then(result => { + resolve({ + 'mrService': mediaRouter, + + 'mrInstanceId': result['instance_id'] || result, + 'mrConfig': result['config'] + }); + }); + }); + }); +}; + + +/** + * @param {!mr.MediaRouterRequestHandler} handlers + * @export + */ +mr.MediaRouterService.prototype.setHandlers; + + +/** + * Starts the MediaRouterService. + * @return {!Promise<!{ + * instance_id: string, + * config: !mojo.MediaRouteProviderConfig + * }>} Resolved with an Object containing the result when MediaRouterService is + * started. + * @export + */ +mr.MediaRouterService.prototype.start; + + +/** + * Returns an Object containing Mojo definitions exported by + * media_router_bindings. + * @return {!Object} + * @export + */ +mr.MediaRouterService.prototype.getMojoExports; + + +/** + * @param {string} source + * @param {!Array.<!mr.Sink>} sinks + * @param {!Array<!mojo.Origin>} origins + * @export + */ +mr.MediaRouterService.prototype.onSinksReceived; + + +/** + * @param {string} pseudoSinkId + * @param {string} sinkId + * @export + */ +mr.MediaRouterService.prototype.onSearchSinkIdReceived; + + +/** + * Used by Media Router Provider Manager to inform Media Router that there is an + * issue. + * @param {!mr.Issue} issue + * @export + */ +mr.MediaRouterService.prototype.onIssue; + + +/** + * Used by Media Router Provider Manager to inform Media Router that the list + * of routes has been updated. + * @param {Array.<mr.Route>} routes + * @param {string=} opt_source + * @param {Array.<string>=} opt_nonLocalJoinableRouteIds + * @export + */ +mr.MediaRouterService.prototype.onRoutesUpdated; + + +/** + * Used by Media Router Provider Manager to inform Media Router that sink + * availability has changed. + * @param {!mr.SinkAvailability} availability + * @export + */ +mr.MediaRouterService.prototype.onSinkAvailabilityUpdated; + + +/** + * Used by Media Router Provider Manager to inform Media Router that the state + * of a presentation connected to a route has changed. + * @param {string} routeId + * @param {string} state + * @export + */ +mr.MediaRouterService.prototype.onPresentationConnectionStateChanged; + + +/** + * Used by Media Router Provider Manager to inform Media Router that the + * presentation connected to a route has closed. + * @param {string} routeId + * @param {string} reason + * @param {string} message + * @export + */ +mr.MediaRouterService.prototype.onPresentationConnectionClosed; + + +/** + * Informs Media Router of route messages received from the media sink to which + * the route is connected. + * @param {string} routeId + * @param {!Array<!mr.RouteMessage>} messages + * @export + */ +mr.MediaRouterService.prototype.onRouteMessagesReceived; + + +/** + * Disables or enables extension event page suspension. + * @param {boolean} keepAlive + * @export + */ +mr.MediaRouterService.prototype.setKeepAlive; + + +/** + * Gets the current keep alive state. + * @return {boolean} + * @export + */ +mr.MediaRouterService.prototype.getKeepAlive; + + +/** + * Informs Media Router that a MirrorServiceRemoter is created for the given + * tab. The Media Router may use remoterPtr to control media remoting. The Media + * Router should also bind sourceRequest to an implementation to receive updates + * on the remoting session. + * @param {number} tabId + * @param {!mojo.MirrorServiceRemoterPtr} remoterPtr + * @param {!mojo.InterfaceRequest} sourceRequest + * @export + */ +mr.MediaRouterService.prototype.onMediaRemoterCreated; + + +/** + * @interface + */ +mr.MediaRouterRequestHandler = function() {}; + + +/** + * Will be called immediately before any other handler method is invoked. + * @export + */ +mr.MediaRouterRequestHandler.prototype.onBeforeInvokeHandler; + + +/** + * Creates a route for |mediaSource| to |sinkId|. + * @param {string} sourceUrn The URN of the media being displayed. + * @param {string} sinkId + * @param {string} presentationId A presentation ID to use. + + * @param {!mojo.Origin|string=} origin + * @param {number=} tabId + * @param {number=} timeoutMillis If positive, the timeout to use in place + * of default timeout. + * @param {boolean=} offTheRecord If true, the request is from an off the + * record (incognito) browser profile. + * @return {!Promise<!mr.Route>} Fulfilled with route created if successful. + * Rejected otherwise. + * @export + */ +mr.MediaRouterRequestHandler.prototype.createRoute; + + +/** + * Joins an existing route. + * + * @param {string} sourceUrn + * @param {string} presentationId A presentation ID for Presentation API + * client; A Cast session ID for Cast join; and 'autojoin' for Cast auto Join + * case; + * @param {!mojo.Origin|string} origin + * @param {number} tabId + * @param {number=} timeoutMillis If positive, the timeout to use in place + * of default timeout. + * @param {boolean=} offTheRecord If true, the request is from an off the + * record (incognito) browser profile. + * @return {!Promise<!mr.Route>} Fulfilled with route joined if successful. + * Rejected otherwise. + * @export + */ +mr.MediaRouterRequestHandler.prototype.joinRoute; + + +/** + * Joins an existing route by route Id. + * + * @param {string} sourceUrn + * @param {string} routeId An existing route Id to join. + * @param {string} presentationId The presentation Id of the route being + * created. + * @param {!mojo.Origin|string} origin + * @param {number} tabId + * @param {number=} timeoutMillis If positive, the timeout to use in place + * of default timeout. + * @return {!Promise<!mr.Route>} Fulfilled with route connected if successful. + * Rejected otherwise. + * @export + */ +mr.MediaRouterRequestHandler.prototype.connectRouteByRouteId; + + +/** + * Terminates the route specified by |routeId|. + * @param {string} routeId The ID of the route to be terminated. + * @return {!Promise<void>} Resolved if the route was terminated, rejected + * otherwise. + * @export + */ +mr.MediaRouterRequestHandler.prototype.terminateRoute; + + +/** + * Starts querying for sinks capable of displaying |sourceUrn|. + * @param {string} sourceUrn The URN of the media. + * @export + */ +mr.MediaRouterRequestHandler.prototype.startObservingMediaSinks; + + +/** + * Stops querying for sinks capable of displaying |sourceUrn|. + * @param {string} sourceUrn The URN of the media. + * @export + */ +mr.MediaRouterRequestHandler.prototype.stopObservingMediaSinks; + + +/** + * Sends a message to the sink via a media route. + * @param {string} routeId + * @param {!Object|string} message The message to post. Object will be + * serialized to a JSON string. + * @param {Object=} opt_extraInfo Extra info about how to send a message. + * @return {!Promise} Fulfilled when the message is posted, or rejected + * if there an error. + * @export + */ +mr.MediaRouterRequestHandler.prototype.sendRouteMessage; + + +/** + * Sends a binary message to the sink via a media route. + * @param {string} routeId + * @param {!Uint8Array} data The binary message to send. + * @return {!Promise} Fulfilled when the message is sent, or rejected + * if there an error. + * @export + */ +mr.MediaRouterRequestHandler.prototype.sendRouteBinaryMessage; + + +/** + * Called when the MediaRouter wants to start receiving messages from the media + * sink for the route identified by |routeId|. + * @param {!string} routeId + * @export + */ +mr.MediaRouterRequestHandler.prototype.startListeningForRouteMessages; + + +/** + * Called when the MediaRouter wants to stop getting further messages + * associated with the routeId. + * @param {!string} routeId + * @export + */ +mr.MediaRouterRequestHandler.prototype.stopListeningForRouteMessages; + + +/** + * Informs the Media Router Provider Manager to send updates on routes list. + * @param {string} sourceUrn The URN of the media. + * @export + */ +mr.MediaRouterRequestHandler.prototype.startObservingMediaRoutes; + + +/** + * Informs the Media Router Provider Manager to stop sending updates on routes + * list. + * @param {string} sourceUrn The URN of the media. + * @export + */ +mr.MediaRouterRequestHandler.prototype.stopObservingMediaRoutes; + + +/** + * Informs the Media Router Provider Manager that a presentation connection has + * detached from its underlying media route due to garbage collection or + * explicit close(). + * @param {!string} routeId + * @export + */ +mr.MediaRouterRequestHandler.prototype.detachRoute; + + +/** + * Enables mDNS discovery. No-ops if it is already enabled. Calling this will + * trigger a firewall prompt on Windows if there is not already a firewall rule + * for mDNS. + * @export + */ +mr.MediaRouterRequestHandler.prototype.enableMdnsDiscovery; + + +/** + * Searches the appropriate provider for a sink matching |searchCriteria| that + * is compatible with |sourceUrn|. The provider that is searched is chosen to be + * the one that owns the pseudo sink identified by |sinkId|. If any sinks are + * found, sinks observers for |sourceUrn| will be invoked via onSinksReceived() + * with those sinks included. The function will return the ID of a sink to which + * a route can be created or the empty string if no such sink exists. + * + * Note that returning the empty string does not necessarily mean no matching + * sinks were found. The provider could find multiple matching sinks and not + * know how to choose a single one for the route creation. The MRPM will return + * the sink on which the user is most likely to create the next route. However, + * the manager does not enforce that the provider creates at most one sink in + * response to a search. + * + * @param {string} sinkId Sink ID of the pseudo sink that generated the request. + * @param {string} sourceUrn Source to be used with the sink. + * @param {!mr.SinkSearchCriteria} searchCriteria Sink search criteria for the + * MRP's which includes the user's current domain. + * @return {!Promise<string>} Fulfilled with the ID of a sink to which a route + * can be created. Rejected otherwise. + * @export + */ +mr.MediaRouterRequestHandler.prototype.searchSinks; + +/** + * Called when Media Router finishes sink discovery. + * @param {string} providerName Name of provider where the sinks come from. + * @param {!Array<!mojo.Sink>} list of sinks discovered by Media Router. + * @export + */ +mr.MediaRouterRequestHandler.prototype.provideSinks; + +/** + * Updates sinks even if sinkAvailability is UNAVAILABLE to allow for query + * based discovery providers an opportuntity to find sinks. + * @param {string} sourceUrn The URN of the media. + * @export + */ +mr.MediaRouterRequestHandler.prototype.updateMediaSinks; + + +/** + * Creates and returns the MediaRouteController instance for the given route. + * Also sets the media status observer for the given route. + * Rejects if the controller cannot be created, or if the controller + * already exists. + * @param {string} routeId + * @param {!mojo.InterfaceRequest} controllerRequest The Mojo request object to + * be bound to the controller created. + * @param {!mojo.MediaStatusObserverPtr} observer The observer's Mojo pointer. + * @return {!Promise<void>} + * @export + */ +mr.MediaRouterRequestHandler.prototype.createMediaRouteController; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route.js new file mode 100644 index 00000000000..b42e4fe42b1 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route.js @@ -0,0 +1,178 @@ +// 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. + +/** + * @fileoverview The media route data object shared between component extension + * and Chrome media router. + */ + +goog.provide('mr.Route'); +goog.require('mr.RouteId'); + +mr.Route = class { + /** + * @param {string} id The route ID. + * @param {string} presentationId The ID of the associated presentation. + * @param {string} sinkId The ID of the sink running this route. + * @param {?string} sourceUrn The media source being sent through this route. + * @param {boolean} isLocal Whether the route is requested locally. + * @param {string} description + * @param {?string} iconUrl + */ + constructor( + id, presentationId, sinkId, sourceUrn, isLocal, description, iconUrl) { + /** + * @type {string} + * @export + */ + this.id = id; + + /** + * The ID of the presentation associated with this route. + * @type {string} + * @export + */ + this.presentationId = presentationId; + + /** + * The ID of the sink associated with this route. + * @type {string} + * @export + */ + this.sinkId = sinkId; + + /** + * The media source being sent through this route. + * Non-null if |isLocal| is true. + * For discovered routes, the media source may not be available. + * @type {?string} + * @export + */ + this.mediaSource = sourceUrn; + + /** + * Whether the route was created locally or is associated with a Cast + * session + * that was created locally. + * @type {boolean} + * @export + */ + this.isLocal = isLocal; + + /** + * @type {string} + * @export + */ + this.description = description; + + /** + * @type {?string} + * @export + */ + this.iconUrl = iconUrl; + + /** + * When false, this route cannot be stopped by the provider managing it. + * @type {boolean} + * @export + */ + this.allowStop = true; + + /** + + * @type {?string} + * @export + */ + this.customControllerPath = null; + + /** + + * @type {boolean} + * @export + */ + this.supportsMediaRouteController = false; + + /** + * The type of controller associated with this route, or kNone if controller + * is not supported for the route. + + * @type {mojo.RouteControllerType} + * @export + */ + this.controllerType = + mojo && mojo.RouteControllerType && mojo.RouteControllerType.kNone; + + /** + * If set to true, this route should be displayed for |sinkId| in UI. + * @type {boolean} + * @export + */ + this.forDisplay = true; + + /** + * If true, the route was created by an off the record (incognito) browser + * profile. + * @type {boolean} + * @export + */ + this.offTheRecord = false; + + /** + * This field is used to identify routes that were created locally as + * opposed + * to isLocal which can identify both locally created routes and non-local + * routes associated with a Cast session that was created locally. The + * initial + * value of this field is the same as isLocal. + * @type {boolean} + */ + this.createdLocally = isLocal; + + /** + * If true, the route was created for offscreen presentation (1-UA mode). + * @type {boolean} + * @export + */ + this.isOffscreenPresentation = false; + } + + /** + * @param {!string} presentationId + * @param {!string} providerName + * @param {!string} sinkId The ID of the sink running this route. + * @param {?string} source The media source being sent through this route. + * @param {!boolean} isLocal Whether the route is requested locally. + * @param {!string} description + * @param {?string} iconUrl + * @return {!mr.Route} + */ + static createRoute( + presentationId, providerName, sinkId, source, isLocal, description, + iconUrl) { + return new mr.Route( + mr.RouteId.getRouteId(presentationId, providerName, sinkId, source), + presentationId, sinkId, source, isLocal, description, iconUrl); + } +}; + +/** + * @typedef {{ + * tabId: (?number|undefined), + * sessionId: string, + * sinkIpAddress: string, + * sinkModelName: string, + * sinkFriendlyName: (string|undefined), + * activity: (!mr.mirror.Activity|undefined) + * }} + */ +mr.Route.MirrorInitData; + + +/** + * The field is only used inside component extension to pass MRP specific data + * to the corresponding mirroring service. For example, cast streaming needs + * sink IP address and session ID. + * @type {mr.Route.MirrorInitData} + */ +mr.Route.prototype.mirrorInitData; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route_message.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route_message.js new file mode 100644 index 00000000000..a796a271b80 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route_message.js @@ -0,0 +1,48 @@ +// 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. + +/** + * @fileoverview The route message object shared between component extension + * and Chrome media router. + */ + +goog.provide('mr.RouteMessage'); + +mr.RouteMessage = class { + /** + * @param {string} routeId + * @param {string|!Uint8Array} message + */ + constructor(routeId, message) { + /** + * @type {string} + * @export + */ + this.routeId = routeId; + + /** + * @type {string|!Uint8Array} + * @export + */ + this.message = message; + } + + /** + * @param {!mr.RouteMessage} routeMessage + * @return {boolean} true if the message is in binary format. + */ + static isBinary(routeMessage) { + return typeof routeMessage.message != 'string'; + } + + /** + * @param {!mr.RouteMessage} routeMessage + * Returns the length of the message if it is a string, otherwise 0. + * @return {number} + */ + static stringLength(routeMessage) { + return mr.RouteMessage.isBinary(routeMessage) ? 0 : + routeMessage.message.length; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route_request_error.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route_request_error.js new file mode 100644 index 00000000000..ac0092f098e --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/route_request_error.js @@ -0,0 +1,89 @@ +// 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. + +/** + * @fileoverview Extension of Error for route request errors. + */ + +goog.provide('mr.RouteRequestError'); +goog.provide('mr.RouteRequestResultCode'); + + +/** + * Keep in sync with: + * - RouteRequestResultCode in media_router.mojom + * - RouteRequestResult::ResultCode in route_request_result.h + * - MediaRouteProviderResult enum in tools/metrics/histograms.xml + * @enum {number} + */ +mr.RouteRequestResultCode = { + UNKNOWN_ERROR: 0, + OK: 1, + TIMED_OUT: 2, + ROUTE_NOT_FOUND: 3, + SINK_NOT_FOUND: 4, + INVALID_ORIGIN: 5, + OFF_THE_RECORD_MISMATCH: 6, + NO_SUPPORTED_PROVIDER: 7, + CANCELLED: 8, +}; + +mr.RouteRequestError = class extends Error { + /** + * @param {!mr.RouteRequestResultCode} errorCode + * @param {string=} opt_message + * @param {string=} opt_stack + */ + constructor(errorCode, opt_message, opt_stack) { + super(); + + this.name = 'RouteRequestError'; + this.message = opt_message || ''; + if (opt_stack) { + this.stack = opt_stack; + } else { + // Attempt to ensure there is a stack trace. + if (Error.captureStackTrace) { + Error.captureStackTrace(this, mr.RouteRequestError); + } else { + const stack = new Error().stack; + if (stack) { + this.stack = stack; + } + } + } + + /** @type {!mr.RouteRequestResultCode} */ + this.errorCode = errorCode; + } + + /** + * If the given error is a mr.RouteRequestError, returns it. Otherwise, a + * returns a mr.RouteRequestError with the error's message and UNKNOWN error + * code. + * @param {*} error + * @return {!mr.RouteRequestError} + */ + static wrap(error) { + if (error instanceof mr.RouteRequestError) { + return error; + } else if (error instanceof Error) { + return new mr.RouteRequestError( + mr.RouteRequestResultCode.UNKNOWN_ERROR, error.message, error.stack); + } else { + return new mr.RouteRequestError(mr.RouteRequestResultCode.UNKNOWN_ERROR); + } + } + + /** + * @param {*} error Possibly an instance of mr.RouteRequestError. + * @return {boolean} True if the argument represents a timeout. + */ + static isTimeout(error) { + if (error instanceof mr.RouteRequestError) { + return error.errorCode == mr.RouteRequestResultCode.TIMED_OUT; + } + return false; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink.js new file mode 100644 index 00000000000..e11b6dcc597 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink.js @@ -0,0 +1,79 @@ +// 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. + +goog.provide('mr.Sink'); +goog.provide('mr.SinkIconType'); + + +/** + * Sink icon types defined in Chrome. + * To define a new icon, add it to Chrome first and then here. + * @enum {string} + */ +mr.SinkIconType = { + CAST: 'cast', + CAST_AUDIO_GROUP: 'cast_audio_group', + CAST_AUDIO: 'cast_audio', + MEETING: 'meeting', + HANGOUT: 'hangout', + EDUCATION: 'education', + GENERIC: 'generic', +}; + + + +/** + * Represents a device which can be a destination for a media route. + */ +mr.Sink = class { + /** + * @param {string} id The ID of the sink. + * @param {string} friendlyName The human readable name of the sink. + * @param {mr.SinkIconType=} iconType + * @param {?string=} description The human readable description of + * the sink. The description is displayed below the sink name, + * and may contain more detailed information about the sink + * (e.g., meeting description). + * @param {?string=} domain The Dasher domain associated with the + * sink. + */ + constructor( + id, friendlyName, iconType = undefined, description = null, + domain = null) { + // For some reason the current public release of jscompiler gets upset if + // mr.SinkIconType.GENERIC is specified as a default argument. + iconType = iconType || mr.SinkIconType.GENERIC; + + /** + * @type {string} + * @export + */ + this.id = id; + + /** + * @type {string} + * @export + */ + this.friendlyName = friendlyName; + + /** + * @type {mr.SinkIconType} + * @export + */ + this.iconType = iconType; + + /** + * @type {?string} + * @export + */ + this.description = description; + + /** + * A non-null domain signifies the sink is tied to a Dasher domain. + * @type {?string} + * @export + */ + this.domain = domain; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink_list.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink_list.js new file mode 100644 index 00000000000..f430d2f0428 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink_list.js @@ -0,0 +1,34 @@ +// 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. + +/** + * @fileoverview Data structure returned by mr.Provider.getAvailableSinks. + */ + +goog.provide('mr.SinkList'); + +mr.SinkList = class { + /** + * @param {!Array<mr.Sink>} sinks + * @param {Array<string>=} opt_origins List of origins that have access to the + * sink list. If not provided, the sink list is accessible from all origins. + */ + constructor(sinks, opt_origins) { + /** + * @type {!Array<!mr.Sink>} + * @export + */ + this.sinks = sinks; + + /** + * @type {?Array<string>} + * @export + */ + this.origins = opt_origins || null; + } +}; + + +/** @const {!mr.SinkList} */ +mr.SinkList.EMPTY = new mr.SinkList([]); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink_search_criteria.js b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink_search_criteria.js new file mode 100644 index 00000000000..9dd8ea094af --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/interface_data/sink_search_criteria.js @@ -0,0 +1,30 @@ +// 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. + +/** + * @fileoverview The sink search criteria shared between component extension and + * Chrome media router. + */ + +goog.provide('mr.SinkSearchCriteria'); + +mr.SinkSearchCriteria = class { + /** + * @param {string} input + * @param {?string} domain + */ + constructor(input, domain) { + /** + * @type {string} + * @export + */ + this.input = input; + + /** + * @type {?string} + * @export + */ + this.domain = domain; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/internal_message.js b/chromium/chrome/browser/resources/media_router/extension/src/internal_message.js new file mode 100644 index 00000000000..debdaab4d6f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/internal_message.js @@ -0,0 +1,62 @@ +// 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. + +/** + * @fileoverview Internal messages between parts of the extension, e.g., event + * page, background page, e2e test page. + * + + */ + +goog.provide('mr.InternalMessage'); +goog.provide('mr.InternalMessageType'); + + +/** + * @enum {string} + */ +mr.InternalMessageType = { + // Feedback ==> event page + SUBSCRIBE_LOG_DATA: 'subscribe_log_data', + RETRIEVE_LOG_DATA: 'retrieve_log_data', + // Control Messages + // App ==> Cloud MRP + START: 'start', + STOP: 'stop', + // Responses + // Cloud MRP ==> App + ROUTE: 'route', + STOPPED: 'stopped', + ERROR: 'error', + // App ==> Log Subscribers + SUBSCRIBED: 'subscribed', + LOG_MESSAGE: 'log_message', +}; + +mr.InternalMessage = class { + /** + * @param {string} source ID of source of message. + * @param {mr.InternalMessageType} type The message type. + * @param {*=} opt_message + */ + constructor(source, type, opt_message) { + /** + * @type {string} + * @export + */ + this.source = source; + + /** + * @type {mr.InternalMessageType} + * @export + */ + this.type = type; + + /** + * @type {*} + * @export + */ + this.message = opt_message; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/internal_message_listener.js b/chromium/chrome/browser/resources/media_router/extension/src/internal_message_listener.js new file mode 100644 index 00000000000..c55869c2b4a --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/internal_message_listener.js @@ -0,0 +1,61 @@ +// 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. + +/** + * @fileoverview Handles internal messages that arrive in the + * chrome.runtime.onMessage event. It is assumed that all incoming messages have + * type mr.InternalMessage. + */ + +goog.provide('mr.InternalMessageListener'); + +goog.require('mr.EventAnalytics'); +goog.require('mr.EventListener'); +goog.require('mr.InternalMessage'); +goog.require('mr.InternalMessageType'); + + +/** + * @extends {mr.EventListener<ChromeEvent>} + */ +mr.InternalMessageListener = class extends mr.EventListener { + constructor() { + super( + mr.EventAnalytics.Event.RUNTIME_ON_MESSAGE, 'InternalMessageListener', + mr.ModuleId.PROVIDER_MANAGER, chrome.runtime.onMessage); + } + + /** + * @override + */ + validateEvent(message, sender, sendResponse) { + const internalMessage = /** @type {mr.InternalMessage} */ (message); + return internalMessage.type == mr.InternalMessageType.RETRIEVE_LOG_DATA && + sender.id == chrome.runtime.id && + sender.url == `chrome-extension://${sender.id}/feedback.html`; + } + + /** + * @override + */ + deferredReturnValue() { + // Indicates the messaging channel should be kept open until + // sendResponse() is called. + return true; + } + + /** + * @return {!mr.InternalMessageListener} + */ + static get() { + if (!mr.InternalMessageListener.listener_) { + mr.InternalMessageListener.listener_ = new mr.InternalMessageListener(); + } + return mr.InternalMessageListener.listener_; + } +}; + + +/** @private {?mr.InternalMessageListener} */ +mr.InternalMessageListener.listener_ = null; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/internal_message_listener_test.js b/chromium/chrome/browser/resources/media_router/extension/src/internal_message_listener_test.js new file mode 100644 index 00000000000..f9e64ae45cb --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/internal_message_listener_test.js @@ -0,0 +1,52 @@ +// 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. + +goog.setTestOnly('internal_message_listener_test'); + +goog.require('mr.InternalMessageListener'); + +describe('Tests mr.InternalMessageListener', () => { + let listener; + + const failCallback = response => { + fail('should not have called back'); + }; + const validEvent = {'type': mr.InternalMessageType.RETRIEVE_LOG_DATA}; + const validSender = { + 'id': 'foo', + 'url': 'chrome-extension://foo/feedback.html' + }; + beforeEach(() => { + chrome.runtime = {id: 'foo'}; + listener = new mr.InternalMessageListener(); + }); + + it('rejects invalid extension id', () => { + const sender = { + 'id': 'invalid', + 'url': 'chrome-extension://invalid/feedback.html' + }; + + const result = listener.validateEvent(validEvent, sender, failCallback); + expect(result).toBe(false); + }); + + it('rejects invalid extension url', () => { + const sender = {'id': 'foo', 'url': 'chrome-extension://foo/invalid.html'}; + + const result = listener.validateEvent(validEvent, sender, failCallback); + expect(result).toBe(false); + }); + + it('rejects invalid message type', () => { + const result = listener.validateEvent( + {'type': mr.InternalMessageType.START}, validSender, failCallback); + expect(result).toBe(false); + }); + + it('passes validation', () => { + const result = listener.validateEvent(validEvent, validSender, () => {}); + expect(result).toBe(true); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/log_manager.js b/chromium/chrome/browser/resources/media_router/extension/src/log_manager.js new file mode 100644 index 00000000000..791aed8c247 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/log_manager.js @@ -0,0 +1,241 @@ +// 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. + +/** + * @fileoverview Log manager which enables and collects both fine and info logs, + * and provides method to get logs with parameter to include or exclude + * fine logs. + + */ + +goog.provide('mr.LogManager'); + +goog.require('mr.Config'); +goog.require('mr.FixedSizeQueue'); +goog.require('mr.Logger'); +goog.require('mr.PersistentData'); +goog.require('mr.PersistentDataManager'); + + +/** + * @implements {mr.PersistentData} + */ +mr.LogManager = class { + constructor() { + /** @private @const */ + this.buffer_ = new mr.FixedSizeQueue(mr.LogManager.BUFFER_SIZE); + + /** @private @const */ + this.startTime_ = Date.now(); + } + + /** + * @return {!mr.LogManager} + */ + static getInstance() { + if (mr.LogManager.instance_ == null) { + mr.LogManager.instance_ = new mr.LogManager(); + } + return mr.LogManager.instance_; + } + + /** + * Init + */ + init() { + mr.Logger.level = this.getDefaultLogLevel_(); + const browserLogger = mr.Logger.getInstance('browser'); + const oldErrorHandler = window.onerror; + /** + * @param {string} message + * @param {string} url + * @param {number} line + * @param {number=} col + * @param {*=} error + */ + window.onerror = (message, url, line, col, error) => { + if (oldErrorHandler) { + oldErrorHandler(message, url, line, col, error); + } + browserLogger.error(`Error: ${message} (${url} @ Line: ${line})`, error); + }; + mr.Logger.addHandler(this.onNewLog_.bind(this)); + + // Override log level via localStorage setting + const debugKey = 'debug.logs'; + const debugLevel = window.localStorage[debugKey]; + if (debugLevel) { + mr.Logger.level = mr.Logger.stringToLevel( + debugLevel.toUpperCase(), mr.Logger.Level.FINE); + } else if (!mr.Config.isPublicChannel) { + // Record the default local level in local settings so developers can + // easily change it without having to look up the name of the setting. + window.localStorage[debugKey] = + mr.Logger.levelToString(mr.Logger.DEFAULT_LEVEL); + } + + const consoleKey = 'debug.console'; + if (!mr.Config.isPublicChannel && window.localStorage[consoleKey] == null) { + // Enable console logging by default in internal builds. Any value other + // than 'false' or '' is treated as true. + window.localStorage[consoleKey] = 'true'; + } + const consoleValue = window.localStorage[consoleKey]; + if (consoleValue && consoleValue.toLowerCase() != 'false') { + mr.Logger.addHandler(this.logToConsole_.bind(this)); + } + } + + /** + * Saves logs in the internal buffer. + * + * @param {mr.Logger.Record} logRecord The log entry. + * @private + */ + onNewLog_(logRecord) { + this.buffer_.enqueue(this.formatRecord_(logRecord, false)); + const exception = logRecord.exception; + if (exception instanceof Error && exception.stack) { + this.buffer_.enqueue(exception.stack); + } + } + + /** + * @param {mr.Logger.Record} logRecord The log entry. + * @private + */ + logToConsole_(logRecord) { + const args = [this.formatRecord_(logRecord, true)]; + if (logRecord.exception) { + args.push(logRecord.exception); + } + switch (logRecord.level) { + case mr.Logger.Level.SEVERE: + console.error(...args); + break; + case mr.Logger.Level.WARNING: + console.warn(...args); + break; + case mr.Logger.Level.INFO: + console.log(...args); + break; + default: + console.debug(...args); + } + } + + /** + * @param {!mr.Logger.Record} record + * @param {boolean} forConsole + * @return {string} + * @private + */ + formatRecord_(record, forConsole) { + const sb = ['[']; + if (forConsole) { + // Format relative timestamp. + const seconds = (Date.now() - this.startTime_) / 1000; + sb.push((' ' + seconds.toFixed(3)).slice(-7)); + } else { + // Format absolute timestamp. + const date = new Date(record.time); + const twoDigitStr = num => num < 10 ? '0' + num : num; + sb.push( + date.getFullYear().toString(), '-', twoDigitStr(date.getMonth() + 1), + '-', twoDigitStr(date.getDate()), ' ', twoDigitStr(date.getHours()), + ':', twoDigitStr(date.getMinutes()), ':', + twoDigitStr(date.getSeconds()), '.', + twoDigitStr(Math.floor(date.getMilliseconds() / 10))); + } + sb.push( + '][', mr.Logger.levelToString(record.level), '][', record.logger, '] ', + record.message); + // Don't append the exception when logging to the console, because it will + // be handled specially later. + if (!forConsole && record.exception != null) { + sb.push('\n'); + if (record.exception instanceof Error) { + sb.push(record.exception.message); + } else { + try { + sb.push(JSON.stringify(record.exception)); + } catch (e) { + sb.push(record.exception.toString()); + } + } + } + sb.push('\n'); + return sb.join(''); + } + + /** + * Get the logs in log buffer. + * @return {string} + */ + getLogs() { + if (this.buffer_.getCount() == 0) { + return 'NA'; + } + + return this.buffer_.getValues().join(''); + } + + /** + * @return {!mr.Logger.Level} The default log level. + * @private + */ + getDefaultLogLevel_() { + return mr.Config.isPublicChannel ? mr.Logger.Level.INFO : + mr.Logger.Level.FINE; + } + + /** + * Registers with the data manager and loads any previous logs. + */ + registerDataManager() { + mr.PersistentDataManager.register(this); + } + + /** + * @override + */ + getStorageKey() { + return 'LogManager'; + } + + /** + * @override + */ + getData() { + return [this.buffer_.getValues()]; + } + + /** + * @override + */ + loadSavedData() { + const currentLogs = this.buffer_.getValues(); + this.buffer_.clear(); + for (let log of mr.PersistentDataManager.getTemporaryData(this) || []) { + this.buffer_.enqueue(log); + } + for (let log of currentLogs) { + this.buffer_.enqueue(log); + } + } +}; + + +/** + * @private {mr.LogManager} + */ +mr.LogManager.instance_ = null; + + +/** + * The max number of logs in buffer. The old logs get pushed out when the buffer + * is full. + * @const + */ +mr.LogManager.BUFFER_SIZE = 1000; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/cancellable_promise.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/cancellable_promise.js new file mode 100755 index 00000000000..0e0807b4575 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/cancellable_promise.js @@ -0,0 +1,255 @@ +// 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. + +goog.module('mr.CancellablePromise'); +goog.module.declareLegacyNamespace(); + + +/** + * A promise packaged together with a cancel() method. + * + * To understand cancellation, for consider a chain of ordinary Promise objects + * P, Q, and R, where Q is the result of calling P.then, and R is the result of + * calling Q.then. Whenever Q is rejected, the rejection is propagated "down" + * the chain to R, but P is unaffected. + * + * Now consider a chain of CancellablePromise objects P, Q, and R, created using + * the chain() method below. Rejection behaves the same as with ordinary + * Promise objects, but whenever Q is cancelled, the cancellation is propagated + * "up" the chain to P. Because a cancelled promise is also rejected, calling + * Q.cancel() also causes the rejection to be propagated to R. In this way, + * cancellation propagates both up and down a chain of related promises. + * + * For an explanation of how this class is used in practice (in the interaction + * between ProviderManager and RouteProvider classes), see the diagram in + * <route-creation-timeout.svg.gz>. + * + * @template T + */ +class CancellablePromise { + /** + * @param {function(function(T), function(*))} init Function + * called immediatiately with resolve and reject functions passed as + * arguments. + * @param {?function(*)=} onCancelled Optional function to be called when this + * CancellablePromise is cancelled. + */ + constructor(init, onCancelled = null) { + /** + * @private {function(*)} + */ + this.reject_; + + /** + * @private {?function(*)} + */ + this.onCancelled_ = onCancelled; + + /** + * @const + */ + this.promise = new Promise((resolve, reject) => { + const resolve1 = value => { + this.onCancelled_ = null; + resolve(value); + }; + const reject1 = reason => { + this.onCancelled_ = null; + reject(reason); + }; + this.reject_ = reject1; + init(resolve1, reject1); + }); + } + + /** + * Cancels this promise. + * + * Does nothing if |this.promise| is already settled. Otherwise, causes + * |this.promise| to be rejected with |reason|, and causes the |onHandled| + * function passed to this class's constructor to be called with |reason|. + * + * @param {*} reason + */ + cancel(reason) { + this.reject_(reason); + if (this.onCancelled_) { + const onCancelled = this.onCancelled_; + this.onCancelled_ = null; // ensure cancel() method is idempotent + setTimeout(() => onCancelled(reason), 0); + } + } + + /** + * Chains CancellablePromises together like the then() method of ordinary + * promises, with one extra feature: if the "child" promise returned by this + * method is cancelled, then this promise is automatically cancelled as well. + * + * @param {?function(T):U} onResolved Function called when this promise is + * resolved. The argument is the value with which this promise was + * resolved. If the function returns, the return value is used to resolve + * the child promise. If the function throws an error, the child promise + * is rejected with that error. If this argument is null, it is + * equivalent to passing a function that returns its argument. + * @param {?function(*):U=} onRejected Function called when this promise is + * rejected. The argument is the reason with which this promise was + * rejected. If the function returns, the return value is used to resolve + * the child promise. If the function throws an error, the child promise + * is rejected with that error. If this argument is missing or null, it + * is equivalent to passing a function that throws its argument. + * @return {!CancellablePromise<U>} A child promise which is resolved or + * rejected depending on the result of calling |onResolved| or + * |onRejected|. + * @template U + */ + chain(onResolved, onRejected = null) { + return new CancellablePromise( + (resolve, reject) => { + this.promise.then( + value => { + if (onResolved) { + try { + resolve(onResolved(value)); + } catch (reason) { + reject(reason); + } + } else { + resolve(value); + } + }, + reason => { + if (onRejected) { + try { + resolve(onRejected(reason)); + } catch (reason2) { + reject(reason2); + } + } else { + reject(reason); + } + }); + }, + reason => { + this.cancel(reason); + }); + } + + /** + * Make it Promise by expose then. + * @param {?function(T):U} onResolved Function called when this promise is + * resolved. The argument is the value with which this promise was + * resolved. If the function returns, the return value is used to resolve + * the child promise. If the function throws an error, the child promise + * is rejected with that error. If this argument is null, it is + * equivalent to passing a function that returns its argument. + * @param {?function(*):U=} onRejected Function called when this promise is + * rejected. The argument is the reason with which this promise was + * rejected. If the function returns, the return value is used to resolve + * the child promise. If the function throws an error, the child promise + * is rejected with that error. If this argument is missing or null, it + * is equivalent to passing a function that throws its argument. + * @return {!CancellablePromise<U>} A child promise which is resolved or + * rejected depending on the result of calling |onResolved| or + * |onRejected|. + * @template U + */ + then(onResolved, onRejected = null) { + return this.chain(onResolved, onRejected); + } + + /** + * Shorthand for .chain(null, onRejected). + * @param {?function(*):T} onRejected + * @return {!CancellablePromise<T>} + */ + catch(onRejected) { + return this.chain(null, onRejected); + } + + /** + * Utility function create a promise in a resolved state. + * @param {T} value + * @return {!CancellablePromise<T>} + * @template T + */ + static resolve(value) { + return new CancellablePromise((resolve, reject) => { + resolve(value); + }); + } + + /** + * Utility function create a promise in a rejected state. + * @param {*} reason + * @return {!CancellablePromise<T>} + * @template T + */ + static reject(reason) { + return new CancellablePromise((resolve, reject) => { + reject(reason); + }); + } + + /** + * Utility function to wrap a Promise. + * + + * + * @param {!Promise<T>} promise + * @return {!CancellablePromise<T>} + * @template T + */ + static forPromise(promise) { + return new CancellablePromise((resolve, reject) => { + promise.then(resolve, reject); + }); + } + + /** + * Produces a CancellablePromise |outer| that runs the following steps: + * + * In the normal case, |outer| waits for a regular (non-cancellable) Promise + * |promise| to resolve to |value|. Then it calls |startCancellableStep|, + * passing |value| as the argument, to produce a CancellablePromise |inner|. + * When |inner| is settled, the result is used to settle |outer|. + * + * If |outer| is cancelled before |promise| is resolved, then |value| is + * discarded and |startCancellableStep| is not called. + * + * If |outer| is cancelled after |promise| is resolved, then |inner| is + * cancelled as well. + * + * If |promise| is rejected, then |outer| is rejected as well. + * + * @param {!Promise<A>} promise + * @param {function(A):!CancellablePromise<B>} startCancellableStep + * @return {!CancellablePromise<B>} + * @template A, B + */ + static withUncancellableStep(promise, startCancellableStep) { + /** @type {boolean} */ + let wasCancelled = false; + /** @type {CancellablePromise} */ + let innerPromise = null; + + return new CancellablePromise( + (resolve, reject) => { + promise.then(value => { + if (!wasCancelled) { + innerPromise = startCancellableStep(value); + innerPromise.promise.then(resolve, reject); + } + }, reject); + }, + reason => { + if (innerPromise) { + innerPromise.cancel(reason); + } else { + wasCancelled = true; + } + }); + } +} + +exports = CancellablePromise; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/route_message_sender.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/route_message_sender.js new file mode 100644 index 00000000000..ca16b6a578c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/route_message_sender.js @@ -0,0 +1,344 @@ +// 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. + +/** + * @fileoverview A sender for sending route message to MR. When MR asks for + * the next batch of messages, this sender sends matching messages if + * available, or buffer the request till message arrives. + * + + */ + +goog.provide('mr.RouteMessageSender'); + +goog.require('mr.Assertions'); +goog.require('mr.Logger'); +goog.require('mr.PersistentData'); +goog.require('mr.PersistentDataManager'); +goog.require('mr.RouteMessage'); +goog.require('mr.ThrottlingSender'); + + +/** + * @implements {mr.PersistentData} + */ +mr.RouteMessageSender = class extends mr.ThrottlingSender { + /** + * @param {!mr.ProviderManagerCallbacks} providerManagerCallbacks + * @param {number} messageSizeKeepAliveThreshold + * @final + */ + constructor(providerManagerCallbacks, messageSizeKeepAliveThreshold) { + super(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + + /** + * Route ID to queue map. One queue per route. + * @private {!Map<string, !Array<!mr.RouteMessage>>} + */ + this.queues_ = new Map(); + + /** + * Set of routes being listened for messages. + * @private {!Set<string>} + */ + this.listeningRouteIds_ = new Set(); + + /** + * Callback to invoke to send a batch of route messages. Set during + * |init()|. The first argument is the route id, and the second argument is + * the batch of messages to send. + * @private {?function(string, !Array<!mr.RouteMessage>)} + */ + this.sendMessagesCallback_ = null; + + /** + * Sum of sizes of all string messages currently queued up. When this is + * above a certain threshold, the extension will be kept alive due to + * performance reasons. + * @private {number} + */ + this.totalMessageSize_ = 0; + + /** + * Number of enqueued binary messages. The extension will be kept alive + * while there are enqueued binary messages. + * @private {number} + */ + this.binaryMessageCount_ = 0; + + /** + * See |totalMessageSize_| and |binaryMessageCount_|. + * @private {boolean} + */ + this.shouldKeepAlive_ = false; + + /** + * @private {!mr.ProviderManagerCallbacks} + */ + this.providerManagerCallbacks_ = providerManagerCallbacks; + + /** + * @private {number} + */ + this.messageSizeKeepAliveThreshold_ = messageSizeKeepAliveThreshold; + + /** @private {mr.Logger} */ + this.logger_ = mr.Logger.getInstance('mr.RouteMessageSender'); + } + + /** + * @param {!function(string, !Array<!mr.RouteMessage>)} sendMessagesCallback + * The callback to invoke to send a batch of route messages. See comments + * on |sendMessagesCallback_|. + */ + init(sendMessagesCallback) { + this.sendMessagesCallback_ = sendMessagesCallback; + mr.PersistentDataManager.register(this); + } + + /** + * Starts listening for route messages associated with |routeId|, and schedule + * a task to send any available messages to the Media Router. + * + * @param {string} routeId + */ + listenForRouteMessages(routeId) { + if (this.listeningRouteIds_.has(routeId)) { + return; + } + + this.listeningRouteIds_.add(routeId); + if (this.hasMessageFrom_(routeId)) { + this.scheduleSend(); + } + } + + /** + * The media router wants to stop getting further messages associated with the + * routeId until it issues listenForRouteMessages again. + * + * @param {string} routeId + */ + stopListeningForRouteMessages(routeId) { + this.listeningRouteIds_.delete(routeId); + } + + /** + * Called when there is a |message| for |routeId| available to be sent. + * @param {string} routeId + * @param {string|!Uint8Array} message + */ + send(routeId, message) { + let queue = this.queues_.get(routeId); + if (!queue) { + queue = []; + this.queues_.set(routeId, queue); + } + + const routeMessage = new mr.RouteMessage(routeId, message); + queue.push(routeMessage); + + // If the queue size for this route has grown suspiciously large, log + // warnings as the queue size grows past the warning threshold. + // + + if (queue.length > mr.RouteMessageSender.QUEUE_SIZE_WARNING_THRESHOLD_ && + queue.length % mr.RouteMessageSender.QUEUE_SIZE_WARNING_THRESHOLD_ == + 1) { + this.logger_.warning( + () => `Message queue length is excessively large ` + + `(${queue.length}) for route ${routeId}`); + } + + this.totalMessageSize_ += mr.RouteMessage.stringLength(routeMessage); + if (mr.RouteMessage.isBinary(routeMessage)) { + this.binaryMessageCount_++; + } + + this.updateShouldKeepAlive_(); + if (this.listeningRouteIds_.has(routeId)) { + this.scheduleSend(); + } + } + + /** + * Removes queue on route removal. + * @param {string} routeId + */ + onRouteRemoved(routeId) { + this.listeningRouteIds_.delete(routeId); + const queue = this.queues_.get(routeId); + if (queue) { + this.queues_.delete(routeId); + this.onMessagesRemoved_(queue); + this.updateShouldKeepAlive_(); + } + } + + /** + * Update message size and binary message counters as |messages| are being + * removed from the queue. + * @param {!Array<!mr.RouteMessage>} messages + * @private + */ + onMessagesRemoved_(messages) { + if (messages.length == 0) { + return; + } + + for (let message of messages) { + this.totalMessageSize_ -= mr.RouteMessage.stringLength(message); + if (mr.RouteMessage.isBinary(message)) { + this.binaryMessageCount_--; + } + } + } + + /** + * @param {string} routeId + * @return {boolean} True if there is at least one message from the route. + * @private + */ + hasMessageFrom_(routeId) { + const queue = this.queues_.get(routeId); + return !!queue && queue.length > 0; + } + + /** + * Computes whether the extension should be kept alive, and informs the + * Provider Manager if that value changed. + * @private + */ + updateShouldKeepAlive_() { + const newShouldKeepAlive = this.binaryMessageCount_ > 0 || + this.totalMessageSize_ > this.messageSizeKeepAliveThreshold_; + if (newShouldKeepAlive != this.shouldKeepAlive_) { + this.shouldKeepAlive_ = newShouldKeepAlive; + this.providerManagerCallbacks_.requestKeepAlive( + this.getStorageKey(), newShouldKeepAlive); + } + } + + /** + * @override + */ + doSend() { + if (!this.sendMessagesCallback_) { + this.logger_.error( + 'sendMessagesCallback not set. Messages not delivered.'); + return; + } + + for (const routeId of this.listeningRouteIds_) { + const queue = this.queues_.get(routeId); + if (!queue || (queue.length == 0)) { + continue; + } + this.sendMessagesCallback_(routeId, queue); + this.onMessagesRemoved_(queue); + this.queues_.set(routeId, []); + } + this.updateShouldKeepAlive_(); + } + + /** + * @override + */ + getStorageKey() { + return 'mr.RouteMessageSender'; + } + + /** + * @override + */ + getData() { + // Assumption: While there are binary messages in any queue, the extension + // keep-alive should be turned on. Thus, we should not encounter binary + // messages while persisting the queues here. + const persistableQueues = [...this.queues_.entries()].map(entry => { + return [ + entry[0], + entry[1].map( + message => mr.Assertions.assertString( + message.message, 'No support for persisting binary messages')) + ]; + }); + return [new mr.RouteMessageSender.PersistentData_( + persistableQueues, Array.from(this.listeningRouteIds_), + this.totalMessageSize_)]; + } + + /** + * @override + */ + loadSavedData() { + const savedData = /** @type {?mr.RouteMessageSender.PersistentData_} */ + (mr.PersistentDataManager.getTemporaryData(this)); + if (savedData) { + this.queues_ = new Map(); + for (const entry of savedData.queues) { + const routeId = /** @type {string} */ (entry[0]); + // Assumption: In getData(), there should not have been any binary + // messages persisted. Therefore, only string messages should be + // restored here. + const queue = (/** @type {!Array<*>} */ (entry[1])).map(message => { + return new mr.RouteMessage( + routeId, + mr.Assertions.assertString( + message, 'No support for restoring binary messages')); + }); + this.queues_.set(routeId, queue); + } + this.listeningRouteIds_ = new Set(savedData.listeningRouteIds); + this.totalMessageSize_ = savedData.totalMessageSize; + } + } +}; + + +/** + * The interval at which messages will be sent back to Media Router. + * @const {number} + */ +mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS = 20; + + +/** + * Generally, no route should have more than 50 messages in queue. However, + * there may be momentary spikes for high-volume communications (e.g., RPC + * traffic). + * @private @const {number} + */ +mr.RouteMessageSender.QUEUE_SIZE_WARNING_THRESHOLD_ = 50; + + +/** + * If the total number of characters in all enqueued string messages exceeds + * this threshold, the extension will be kept alive for performance reasons. + * @const {number} + */ +mr.RouteMessageSender.MESSAGE_SIZE_KEEP_ALIVE_THRESHOLD = 512 * 1024; + + +/** + * @private + */ +mr.RouteMessageSender.PersistentData_ = class { + /** + * @param {!Array<Array<*>>} queues An array where each element is an + * 2-element array of [routeId, messages]. + * @param {!Array<string>} listeningRouteIds + * @param {number} totalMessageSize + */ + constructor(queues, listeningRouteIds, totalMessageSize) { + /** @type {!Array<Array<*>>} */ + this.queues = queues; + + /** @type {!Array<string>} */ + this.listeningRouteIds = listeningRouteIds; + + /** @type {number} */ + this.totalMessageSize = totalMessageSize; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/route_message_sender_test.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/route_message_sender_test.js new file mode 100644 index 00000000000..31b885e82dc --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/route_message_sender_test.js @@ -0,0 +1,173 @@ +// 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. + +goog.setTestOnly(); +goog.require('mr.MockClock'); +goog.require('mr.PersistentDataManager'); +goog.require('mr.RouteMessage'); +goog.require('mr.RouteMessageSender'); +goog.require('mr.UnitTestUtils'); + + + +describe('Tests RouteMessageSender', function() { + let mockClock; + let providerManagerCallbacks; + let callback; + let sender; + const routeId1 = 'r1'; + const routeId2 = 'r2'; + const text1 = 'message1'; + const messageSizeThreshold = 100; + + beforeEach(function() { + mr.UnitTestUtils.mockChromeApi(); + chrome.runtime.onSuspend.addListener = l => { + onSuspendListener = l; + }; + + mr.PersistentDataManager.clear(); + mockClock = new mr.MockClock(true); + providerManagerCallbacks = + jasmine.createSpyObj('pmCallbacks', ['requestKeepAlive']); + sender = new mr.RouteMessageSender( + providerManagerCallbacks, messageSizeThreshold); + callback = jasmine.createSpy('sendCallback'); + sender.init(callback); + }); + + afterEach(function() { + mr.PersistentDataManager.clear(); + mockClock.uninstall(); + mr.UnitTestUtils.restoreChromeApi(); + }); + + it('hasMessageFrom_', function() { + expect(sender.hasMessageFrom_(routeId1)).toBe(false); + sender.send(routeId1, text1); + expect(sender.hasMessageFrom_(routeId1)).toBe(true); + }); + + it('No msg when requested; new msg sent when arrives', function() { + sender.listenForRouteMessages(routeId1); + sender.send(routeId1, text1); + expect(callback).toHaveBeenCalledWith( + routeId1, [new mr.RouteMessage(routeId1, text1)]); + expect(sender.hasMessageFrom_(routeId1)).toBe(false); + }); + + it('Has msg when requested', function() { + sender.send(routeId1, text1); + expect(callback).not.toHaveBeenCalled(); + + sender.listenForRouteMessages(routeId1); + expect(callback).toHaveBeenCalledWith( + routeId1, [new mr.RouteMessage(routeId1, text1)]); + mockClock.tick(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + expect(sender.hasMessageFrom_(routeId1)).toBe(false); + }); + + it('onRouteRemoved removes messages', function() { + sender.send(routeId1, text1); + expect(sender.hasMessageFrom_(routeId1)).toBe(true); + sender.onRouteRemoved(routeId1); + expect(sender.hasMessageFrom_(routeId1)).toBe(false); + + expect(sender.totalMessageSize_).toEqual(0); + expect(sender.binaryMessageCount_).toEqual(0); + expect(sender.shouldKeepAlive_).toBe(false); + }); + + it('stopListeningForRouteMessages does not remove messages', function() { + sender.send(routeId1, text1); + expect(sender.hasMessageFrom_(routeId1)).toBe(true); + sender.stopListeningForRouteMessages(routeId1); + expect(sender.hasMessageFrom_(routeId1)).toBe(true); + }); + + it('requestKeepAlive due to binary message', function() { + const binaryArray1 = new Uint8Array(12); + const binaryArray2 = new Uint8Array(34); + sender.send(routeId1, binaryArray1); + expect(providerManagerCallbacks.requestKeepAlive) + .toHaveBeenCalledWith(sender.getStorageKey(), true); + providerManagerCallbacks.requestKeepAlive.calls.reset(); + + sender.send(routeId2, binaryArray2); + expect(providerManagerCallbacks.requestKeepAlive).not.toHaveBeenCalled(); + + sender.listenForRouteMessages(routeId1); + mockClock.tick(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + expect(callback).toHaveBeenCalledWith( + routeId1, [new mr.RouteMessage(routeId1, binaryArray1)]); + expect(providerManagerCallbacks.requestKeepAlive).not.toHaveBeenCalled(); + + sender.listenForRouteMessages(routeId2); + mockClock.tick(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + expect(callback).toHaveBeenCalledWith( + routeId2, [new mr.RouteMessage(routeId2, binaryArray2)]); + expect(providerManagerCallbacks.requestKeepAlive) + .toHaveBeenCalledWith(sender.getStorageKey(), false); + + expect(sender.totalMessageSize_).toEqual(0); + expect(sender.binaryMessageCount_).toEqual(0); + expect(sender.shouldKeepAlive_).toBe(false); + }); + + it('requestKeepAlive due to message size threshold', function() { + const message1 = 'a'.repeat(messageSizeThreshold / 2); + const message2 = 'b'.repeat(messageSizeThreshold / 2 + 1); + + sender.send(routeId1, message1); + expect(providerManagerCallbacks.requestKeepAlive).not.toHaveBeenCalled(); + + sender.send(routeId2, message2); + expect(providerManagerCallbacks.requestKeepAlive) + .toHaveBeenCalledWith(sender.getStorageKey(), true); + providerManagerCallbacks.requestKeepAlive.calls.reset(); + + sender.listenForRouteMessages(routeId1); + mockClock.tick(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + expect(callback).toHaveBeenCalledWith( + routeId1, [new mr.RouteMessage(routeId1, message1)]); + expect(providerManagerCallbacks.requestKeepAlive) + .toHaveBeenCalledWith(sender.getStorageKey(), false); + + sender.listenForRouteMessages(routeId2); + mockClock.tick(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + expect(callback).toHaveBeenCalledWith( + routeId2, [new mr.RouteMessage(routeId2, message2)]); + + expect(sender.totalMessageSize_).toEqual(0); + expect(sender.binaryMessageCount_).toEqual(0); + expect(sender.shouldKeepAlive_).toBe(false); + }); + + it('saves pending messages, then restores and sends them', function() { + const textMessage = 'this is a text message'; + const textMessage2 = 'this is another text message'; + + // Queue up the messages in the RouteMessageSender. They will not be sent + // because listenForRouteMessages() has not been called yet. + sender.send(routeId1, textMessage); + sender.send(routeId1, textMessage2); + + // Persists pending messages. + mr.PersistentDataManager.suspendForTest(); + + // Re-creating a new RouteMessageSender restores the pending messages. + sender = new mr.RouteMessageSender( + providerManagerCallbacks, messageSizeThreshold); + sender.init(callback); + + // Now, call listenForRouteMessages() and the restored pending messages + // should be processed. + sender.listenForRouteMessages(routeId1); + mockClock.tick(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + expect(callback).toHaveBeenCalledWith(routeId1, [ + new mr.RouteMessage(routeId1, textMessage), + new mr.RouteMessage(routeId1, textMessage2), + ]); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/throttling_sender.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/throttling_sender.js new file mode 100644 index 00000000000..730959a9b91 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/throttling_sender.js @@ -0,0 +1,89 @@ +// 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. + +/** + * @fileoverview An abstract throttling sender. It sends right away if there is + * no sending in the past 'interval' time. Otherwise, it waits till 'interval' + * is passed. + * + + */ + +goog.provide('mr.ThrottlingSender'); + + +/** + * Note: Strictly speaking, this class should implement PersistentData to store + * lastSendTime_. But since we never specify an interval large enough for the + * extension to become suspended in between, it is not needed in practice. + */ +mr.ThrottlingSender = class { + /** + * @param {number} interval in milliseconds. + */ + constructor(interval) { + /** @private {number} */ + this.interval_ = interval; + + /** @private {?number} */ + this.lastSendTime_ = null; + + /** @private {?number} */ + this.timerId_ = null; + } + + /** + * Clears the sender timer and sets it to null. + * @private + */ + clearTimer_() { + if (this.timerId_ != null) { + clearTimeout(this.timerId_); + this.timerId_ = null; + } + } + + /** + * Schedule a send. + * @protected + */ + scheduleSend() { + if (this.timerId_ != null) { + return; + } + if (this.lastSendTime_ == null || + Date.now() - this.lastSendTime_ >= this.interval_) { + // Send right away + this.send_(); + } else { + // Delay a while + const delay = + Math.max(this.lastSendTime_ + this.interval_ - Date.now(), 5); + this.timerId_ = setTimeout(this.send_.bind(this), delay); + } + } + + /** + * Sends messages immediately. + */ + sendImmediately() { + this.send_(); + } + + /** + * Sends right away and schedule another send if there is more to send. + * @private + */ + send_() { + this.clearTimer_(); + this.doSend(); + this.lastSendTime_ = Date.now(); + } + + /** + * Sends the message if available. + * @protected + */ + doSend() {} +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/throttling_sender_test.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/throttling_sender_test.js new file mode 100644 index 00000000000..fd3de97a161 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/mr_event_senders/throttling_sender_test.js @@ -0,0 +1,69 @@ +// 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. + +goog.setTestOnly(); +goog.require('mr.MockClock'); +goog.require('mr.ThrottlingSender'); + +describe('Tests ThrottlingSender', function() { + let mockClock; + let sender; + const interval = 50; + let numOfMessages; + + beforeEach(function() { + mockClock = new mr.MockClock(true); + sender = new mr.ThrottlingSender(interval); + numOfMessages = 0; + sender.doSend = jasmine.createSpy('doSend').and.callFake(() => { + numOfMessages--; + }); + }); + + afterEach(function() { + mockClock.uninstall(); + }); + + it('first msg is sent right away', function() { + numOfMessages++; + sender.scheduleSend(); + expect(sender.doSend.calls.count()).toEqual(1); + expect(numOfMessages).toEqual(0); + mockClock.tick(interval); + expect(sender.doSend.calls.count()).toEqual(1); + expect(numOfMessages).toEqual(0); + }); + + it('messages are throttled', function() { + numOfMessages++; + sender.scheduleSend(); + expect(sender.doSend.calls.count()).toEqual(1); + expect(numOfMessages).toEqual(0); + + numOfMessages++; + sender.scheduleSend(); + expect(sender.doSend.calls.count()).toEqual(1); + expect(numOfMessages).toEqual(1); + + mockClock.tick(interval); + expect(sender.doSend.calls.count()).toEqual(2); + expect(numOfMessages).toEqual(0); + }); + + it('messages are sent gradually 1', function() { + numOfMessages++; + sender.scheduleSend(); + expect(sender.doSend.calls.count()).toEqual(1); + mockClock.tick(interval / 2); + expect(sender.doSend.calls.count()).toEqual(1); + + numOfMessages++; + sender.scheduleSend(); + expect(sender.doSend.calls.count()).toEqual(1); + mockClock.tick(interval / 2); + expect(sender.doSend.calls.count()).toEqual(2); + mockClock.tick(interval); + expect(sender.doSend.calls.count()).toEqual(2); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/presentation_enums.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/presentation_enums.js new file mode 100644 index 00000000000..3ce170b66ea --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/presentation_enums.js @@ -0,0 +1,35 @@ +// 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. + +/** + * @fileoverview Defines presentation connection related enums. + */ + +goog.provide('mr.PresentationConnectionCloseReason'); +goog.provide('mr.PresentationConnectionState'); + + +/** + * Presentation connection states. Keep in sync with PresentationConnection.idl + * in the Chromium code base. + * + * @enum {string} + */ +mr.PresentationConnectionState = { + CONNECTED: 'connected', + TERMINATED: 'terminated', + CLOSED: 'closed' +}; + + +/** + * Keep in sync with PresentationConnectionCloseEvent.idl in Chromium code base. + * + * @enum {string} + */ +mr.PresentationConnectionCloseReason = { + ERROR: 'error', + CLOSED: 'closed', + WENT_AWAY: 'went_away' +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/provider.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider.js new file mode 100644 index 00000000000..fe0217eff71 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider.js @@ -0,0 +1,258 @@ +// 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. + +/** + * @fileoverview API for media route providers. + * + + */ + +goog.provide('mr.Provider'); +goog.provide('mr.ProviderName'); + +goog.require('mr.CancellablePromise'); + +/** + * @enum {string} + */ +mr.ProviderName = { + CAST: 'cast', + DIAL: 'dial', + CLOUD: 'cloud', + TEST: 'test' +}; + + + +/** + * @record + */ +mr.Provider = class { + /** + * Gets provider name. + * @return {string} + */ + getName() {} + + /** + * Called to send a message to the sink via a media route. + * @param {string} routeId + * @param {!Object|string} message The message to send. Object will be + * serialized to a JSON string. + * @param {Object=} opt_extraInfo Extra info about how to send a message. + * @return {!Promise<void>} Fulfilled when the message is posted, or rejected + * if there an error. + */ + sendRouteMessage(routeId, message, opt_extraInfo) {} + + /** + * Called to send a binary message to the sink via a media route. + * @param {string} routeId + * @param {!Uint8Array} data The message to send. + * @return {!Promise<void>} Fulfilled when the message is posted, or rejected + * if there an error. + */ + sendRouteBinaryMessage(routeId, data) {} + + /** + * Called the first time the provider is loaded. + * Should do any one-time initialization (i.e. register event filters, etc.) + * @param {!mojo.MediaRouteProviderConfig=} config The config object from the + * browser to initialize the provider with. + */ + initialize(config = undefined) {} + + /** + * Queries this provider for existing routes. + * + * @return {!Array.<!mr.Route>} + */ + getRoutes() {} + + /** + * Queries this provider for available sinks. + * + * @param {string} sourceUrn + * @return {!mr.SinkList} + */ + getAvailableSinks(sourceUrn) {} + + /** + * Starts querying for sinks capable of displaying |sourceUrn|. + * @param {string} sourceUrn The URN of the media. + */ + startObservingMediaSinks(sourceUrn) {} + + /** + * Stops querying for sinks capable of displaying |sourceUrn|. + * @param {string} sourceUrn The URN of the media. + */ + stopObservingMediaSinks(sourceUrn) {} + + /** + * Informs the provider to send updates on routes list. + * @param {string} sourceUrn The URN of the media. + */ + startObservingMediaRoutes(sourceUrn) {} + + /** + * Informs the provider to stop sending updates on routes list. + * @param {string} sourceUrn The URN of the media. + */ + stopObservingMediaRoutes(sourceUrn) {} + + /** + * Queries this provider for a sink by id. + * + * @param {string} sinkId + * @return {?mr.Sink} + * Null if no sink exists with sinkId. + */ + getSinkById(sinkId) {} + + /** + * Creates a new route to the sink. + * @param {string} sourceUrn + * @param {string} sinkId The ID of the target sink. + * @param {string} presentationId A presentation ID to use. + * @param {boolean} offTheRecord True if the request is from an + * off the record (incognito) browser profile. + * @param {number} timeoutMillis Request timeout in milliseconds. + * @param {string=} opt_origin + * @param {number=} opt_tabId + * @return {!mr.CancellablePromise<!mr.Route>} Fulfilled with route created if + * successful. Rejected otherwise. + */ + createRoute( + sourceUrn, sinkId, presentationId, offTheRecord, timeoutMillis, + opt_origin, opt_tabId) {} + + /** + * Terminates the media route owned by this provider. + * @param {string} routeId The media route id. + * @return {!Promise} Fulfilled when route is terminated, or rejected with + * an error. + */ + terminateRoute(routeId) {} + + /** + * Creates and computes mirror settings appropriate for the given sink (and + * the sender's capabilities). See class comments for mr.mirror.Settings when + * overriding this method. Returns null if this provider does not support the + * sink. + * + * The provider must return valid, frozen settings if + * provider.canRoute(sourceUrn, sinkId) is true. + * + * @param {string} sinkId The ID of the sink to mirror to. + * @return {!mr.mirror.Settings} + */ + getMirrorSettings(sinkId) {} + + /** + * Gets the name of the best mirror service supported by this provider + * on the sink |sinkId|. + * + * Note that provider must return a valid mirror service name if + * provider.canRoute(sourceUrn, sinkId) is true, where sourcerUrn is any + * valid mirroring URN. + * + * @param {string} sinkId + * @return {?mr.mirror.ServiceName} + * Null if no mirror service is supported on the sink. + */ + getMirrorServiceName(sinkId) {} + + /** + * Tells the provider that the mirroring activity description for the + * mirroring route |routeId| has changed. The provider can synchronize this + * with its own state. + * @param {string} routeId + */ + onMirrorActivityUpdated(routeId) {} + + /** + * Whether this provider can route media |sourceUrn| to sink |sinkId|. + * @param {string} sourceUrn The URN of the media being displayed. + * @param {string} sinkId + * @return {boolean} True if the provider can handle it. + */ + canRoute(sourceUrn, sinkId) {} + + /** + * Whether this provider can join a given route from |sourceUrn| and, + * optionally, the specific |route| in question. + * @param {string} sourceUrn The URN of the media being displayed. + * @param {string=} presentationId The presentation ID to join. + * @param {mr.Route=} route The route to join. + * @return {boolean} True if the provider can handle it. + */ + canJoin(sourceUrn, presentationId = undefined, route = undefined) {} + + /** + * Joins a route identified by by |sourceUrn| and |presentationId|. + * @param {string} sourceUrn + * @param {string} presentationId A presentation ID for Presentation API + * client; A Cast session ID for Cast join; and 'autojoin' for Cast auto Join + * case; + * @param {boolean} offTheRecord True if the request is from an + * off the record (incognito) browser profile. + * @param {number} timeoutMillis Request timeout in milliseconds. + * @param {string} origin + * @param {?number} tabId null for packaged app. + * @return {!mr.CancellablePromise<!mr.Route>} Fulfilled with the route if + * joined; Rejected otherwise. + */ + joinRoute( + sourceUrn, presentationId, offTheRecord, timeoutMillis, origin, tabId) {} + + /** + * Joins a route identified by by |sourceUrn| and |routeId|. + * @param {string} sourceUrn + * @param {string} routeId A route ID to join. + * @param {string} presentationId The presentation ID of the route to be + * created. + * @param {string} origin + * @param {?number} tabId null for packaged app. + * @param {number=} opt_timeoutMillis If positive, the timeout to use in place + * of default timeout. + * @return {!mr.CancellablePromise<!mr.Route>} Fulfilled with the route if + * joined; Rejected otherwise. + */ + connectRouteByRouteId( + sourceUrn, routeId, presentationId, origin, tabId, opt_timeoutMillis) {} + + /** + * Detaches a presentation connection from the underlying media route given by + * |routeId|. + * @param {!string} routeId + */ + detachRoute(routeId) {} + + /** + * Searches this provider for a sink that matches |searchCriteria| that is + * compatible with the source |sourceUrn|. Returns a promise which resolves + * to the matching sink, or rejected if not found. + * @param {string} sourceUrn + * @param {!mr.SinkSearchCriteria} searchCriteria + * @return {!Promise<!mr.Sink>} + */ + searchSinks(sourceUrn, searchCriteria) {} + + /** + * Called when Media Router finishes sink discovery. Store |sinks| in this + * provider. + * @param {!Array<!mojo.Sink>} sinks list of discovered sinks + */ + provideSinks(sinks) {} + + /** + * See documentation in interface_data/mojo.js. + * @param {string} routeId + * @param {!mojo.InterfaceRequest} controllerRequest + * @param {!mojo.MediaStatusObserverPtr} observer + * @return {!Promise<void>} + */ + createMediaRouteController(routeId, controllerRequest, observer) {} +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_events.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_events.js new file mode 100644 index 00000000000..564180e723f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_events.js @@ -0,0 +1,46 @@ +// 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. + +/** + * @fileoverview Provider events. + + */ + +goog.provide('mr.InternalMessageEvent'); +goog.provide('mr.ProviderEventType'); + + +/** + * @enum {string} + */ +mr.ProviderEventType = { + INTERNAL_MESSAGE: 'internal_message' +}; + + +/** + * @template T + */ +mr.InternalMessageEvent = class { + /** + * @param {string} routeId + * @param {T} message + */ + constructor(routeId, message) { + /** + * @const + */ + this.type = mr.ProviderEventType.INTERNAL_MESSAGE; + + /** + * @type {string} + */ + this.routeId = routeId; + + /** + * @type {T} + */ + this.message = message; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager.js new file mode 100755 index 00000000000..94e50f9792f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager.js @@ -0,0 +1,1280 @@ +// 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. + +/** + * @fileoverview API for registering Media Route Providers. + * + + * + * For now, we will always load every provider each time the extension is + * loaded. + */ + +goog.provide('mr.ProviderManager'); + +goog.require('mr.Assertions'); +goog.require('mr.CancellablePromise'); +goog.require('mr.EventAnalytics'); +goog.require('mr.EventTarget'); +goog.require('mr.InitHelper'); +goog.require('mr.InternalMessageEvent'); +goog.require('mr.Logger'); +goog.require('mr.MediaRouterRequestHandler'); +goog.require('mr.MessagePortService'); +goog.require('mr.MessagePortServiceImpl'); +goog.require('mr.MirrorAnalytics'); +goog.require('mr.Module'); +goog.require('mr.MojoUtils'); +goog.require('mr.PersistentData'); +goog.require('mr.PersistentDataManager'); +goog.require('mr.ProviderManagerCallbacks'); +goog.require('mr.RouteMessageSender'); +goog.require('mr.RouteRequestError'); +goog.require('mr.RouteRequestResultCode'); +goog.require('mr.Sink'); +goog.require('mr.SinkAvailability'); +goog.require('mr.SinkSearchCriteria'); +goog.require('mr.Throttle'); +goog.require('mr.mirror.Error'); +goog.require('mr.mirror.ServiceName'); + + +/** + * Tracks registered MediaRouteProviders and loads them on-demand. + * @implements {mr.MediaRouterRequestHandler} + * @implements {mr.PersistentData} + * @implements {mr.ProviderManagerCallbacks} + */ +mr.ProviderManager = class extends mr.Module { + constructor() { + super(); + + /** + * @private {mr.Logger} + */ + this.logger_ = mr.Logger.getInstance('mr.ProviderManager'); + + /** + * Holds a an array of registered Media Route Providers. + * @private {!Array<!mr.Provider>} + */ + this.providers_ = []; + + /** + * Holds a map of route ID to the provider that is managing the route. + * @private {!Map<string, mr.Provider>} + */ + this.routeIdToProvider_ = new Map(); + + /** + * Holds a map of route ID to the mirror service name if the route is for + * tab/desktop mirroring. + * @private {!Map<string, mr.mirror.ServiceName>} + */ + this.routeIdToMirrorServiceName_ = new Map(); + + /** + * Holds a set of active sink queries. + * @private {!Set<string>} + */ + this.sinkQueries_ = new Set(); + + /** + * Holds a set of active route queries. + * @private {!Set<string>} + */ + this.routeQueries_ = new Set(); + + /** + * Holds a map of mirror service names to modules. + * @private {!Map<mr.mirror.ServiceName, mr.ModuleId>} + */ + this.mirrorServiceModules_ = new Map(); + + /** + * Keeps track of last used mirror service for gathering logs for feedback. + * @private {?mr.mirror.ServiceName} + */ + this.lastUsedMirrorService_ = null; + + /** @private {?mr.MediaRouterService} */ + this.mediaRouterService_ = null; + + /** @private {!mr.EventTarget} */ + this.routeMessageEventTarget_ = new mr.EventTarget(); + + + /** @private {!mr.Throttle} */ + this.routeUpdateEventThrottle_ = new mr.Throttle( + this.sendRoutesQueryResultToMr_, + mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_, this); + + /** @private {!mr.Throttle} */ + this.sinkUpdateEventThrottle_ = new mr.Throttle( + this.executeSinkQueries_, mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_, + this); + + /** + * MR message sender with build-in rate throttle. + * @private {!mr.RouteMessageSender} + */ + this.mrRouteMessageSender_ = new mr.RouteMessageSender( + this, mr.RouteMessageSender.MESSAGE_SIZE_KEEP_ALIVE_THRESHOLD); + + /** + * The number of pending createRoute + * @private {number} + */ + this.pendingRequestRoutes_ = 0; + + /** + * Maps provider name to that provider's last reported sink availability. + * @private {!Map<string, mr.SinkAvailability>} + */ + this.sinkAvailabilityMap_ = new Map(); + + /** + * Whether mDNS is currently enabled. + * @private {boolean} + */ + this.mdnsEnabled_ = window.navigator.userAgent.indexOf('Windows') == -1; + + /** + * Functions that should be executed when |mdnsEnabled_| goes from |false| + * to + * |true|. + * @private {!Array<function()>} + */ + this.mdnsEnabledCallbacks_ = []; + + /** + * Names of components that have requested to keep the extension alive. + * @private {!Array<string>} + */ + this.keepAliveComponents_ = []; + + /** + * Handler for chrome.runtime.onMessage events. + * @private @const + */ + this.internalMessageHandler_ = + mr.InitHelper.getInternalMessageHandler(this); + + /** + * Handler for chrome.runtime.onMessageExternal events. + * @private @const + */ + this.externalMessageHandler_ = + mr.InitHelper.getExternalMessageHandler(this); + + mr.ProviderManager.exportProperties_(this); + } + + /** + * @override + */ + handleEvent(event, ...args) { + if (event == chrome.runtime.onMessage) { + this.internalMessageHandler_(...args); + } else if (event == chrome.runtime.onMessageExternal) { + this.externalMessageHandler_(...args); + } else { + throw new Error('Unhandled event'); + } + } + + /** + * Gets the mirror service with the given name. + * @param {mr.mirror.ServiceName} serviceName Name of the mirror service. + * @return {!Promise<!mr.mirror.Service>} Resolved with the requested + * mirror service. + */ + getMirrorService(serviceName) { + const moduleId = this.mirrorServiceModules_.get(serviceName); + return mr.Module.load(moduleId).then(module => { + let service = /** @type {!mr.mirror.Service} */ (module); + service.initialize(this); + return service; + }); + } + + /** + * Registers and initalizes providers. + * + * @param {!Array<!mr.Provider>} providers + * @param {!mojo.MediaRouteProviderConfig=} config + */ + registerAllProviders(providers, config = undefined) { + providers.forEach(provider => { + this.registerProvider_(provider, config); + }); + } + + /** + * Initializes the provider manager, register / initialize given providers, + * and registers itself with PersistentDataManager. + * @param {!mr.MediaRouterService} mediaRouterService + * @param {!Array<!mr.Provider>} providers + * @param {!mojo.MediaRouteProviderConfig=} config + */ + initialize(mediaRouterService, providers, config = undefined) { + this.mirrorServiceModules_.set( + mr.mirror.ServiceName.WEBRTC, mr.ModuleId.WEBRTC_STREAMING_SERVICE); + this.mirrorServiceModules_.set( + mr.mirror.ServiceName.CAST_STREAMING, + mr.ModuleId.CAST_STREAMING_SERVICE); + this.mirrorServiceModules_.set( + mr.mirror.ServiceName.HANGOUTS, mr.ModuleId.HANGOUTS_SERVICE); + this.mirrorServiceModules_.set( + mr.mirror.ServiceName.MEETINGS, mr.ModuleId.MEETINGS_SERVICE); + + mr.MessagePortService.setService(new mr.MessagePortServiceImpl(this)); + + this.mediaRouterService_ = mediaRouterService; + this.mrRouteMessageSender_.init( + this.mediaRouterService_.onRouteMessagesReceived.bind( + this.mediaRouterService_)); + this.registerAllProviders(providers, config); + + mr.PersistentDataManager.register(this); + mr.Module.onModuleLoaded(mr.ModuleId.PROVIDER_MANAGER, this); + } + + /** + * Registers a provider and initializes it. + * @param {!mr.Provider} provider + * @param {!mojo.MediaRouteProviderConfig=} config + * @private + */ + registerProvider_(provider, config = undefined) { + if (this.getProviderByName(provider.getName())) { + this.logger_.warning( + 'Provider ' + provider.getName() + ' already registered.'); + return; + } + + try { + provider.initialize(config); + this.providers_.push(provider); + this.sinkAvailabilityMap_.set( + provider.getName(), mr.SinkAvailability.UNAVAILABLE); + } catch (/** Error */ error) { + this.logger_.warning( + 'Provider ' + provider.getName() + ' failed to initialize.', error); + } + } + + /** + * @return {!Array<!mr.Provider>} Registered Media Route Providers. + */ + getProviders() { + return this.providers_; + } + + /** + * @param {!mr.CancellablePromise<!mr.Route>} routePromise + * @param {number} timeout Timeout in milliseconds. When timeout + * fires, the return value of this method is rejected. Must be a positive + * number. + * @return {!Promise<!mr.Route>} + * @private + */ + addTimeout_(routePromise, timeout) { + return new Promise((resolve, reject) => { + let timerId = null; + this.preventSuspend_(); + timerId = window.setTimeout(() => { + timerId = null; + // Refer to the CancellablePromise class for more details on the + // cancel() method, and <route-creation-timeout.svg.gz> for a diagram of + // how promise cancellation works with this class. + routePromise.cancel(new mr.RouteRequestError( + mr.RouteRequestResultCode.TIMED_OUT, + 'timeout after ' + timeout + ' ms.')); + }, timeout); + const cleanup = () => { + this.allowSuspend_(); + if (timerId != null) { + window.clearTimeout(timerId); + } + }; + routePromise.promise.then( + route => { + cleanup(); + resolve(route); + }, + err => { + cleanup(); + reject(mr.RouteRequestError.wrap(err)); + }); + }); + } + + /** + * @param {!Promise<T>} promise + * @param {number} timeout Timeout in milliseconds. When timeout + * fires, the return value of this method is rejected. Must be a positive + * number. + * @return {!Promise<T>} + * @template T + * @private + */ + addIgnoredTimeout_(promise, timeout) { + return this.addTimeout_(mr.CancellablePromise.forPromise(promise), timeout); + } + + /** + * Increments the number of pending request for routes and checks if the + * extension should be kept alive. + * @private + */ + preventSuspend_() { + this.pendingRequestRoutes_++; + this.maybeUpdateKeepAlive_(); + } + + /** + * Decrements the number of pending request for routes and checks if the + * extension should be kept alive. + * @private + */ + allowSuspend_() { + this.pendingRequestRoutes_--; + this.maybeUpdateKeepAlive_(); + } + + /** + * If override timeout is provided and is positive, returns it. + * Otherwise, returns the default timeout. + * @param {number} defaultTimeoutMillis Default timeout. + * @param {number=} opt_overrideTimeoutMillis Optional override timeout. + * @return {number} + * @private + */ + static getTimeoutToUse_(defaultTimeoutMillis, opt_overrideTimeoutMillis) { + return opt_overrideTimeoutMillis && opt_overrideTimeoutMillis > 0 ? + opt_overrideTimeoutMillis : + defaultTimeoutMillis; + } + + /** @override */ + onBeforeInvokeHandler() { + mr.EventAnalytics.recordEvent(mr.EventAnalytics.Event.MEDIA_ROUTER); + } + + /** + * Helper method to return a string origin from a string or a mojo.Origin + * object. + + * @param {!mojo.Origin|string} origin + * @return {string} + * @private + */ + mojoOriginToString_(origin) { + if (typeof origin == 'string') { + return origin; + } + return mr.MojoUtils.mojoOriginToString(/** @type{!mojo.Origin} */ (origin)); + } + + /** + * @override + */ + createRoute( + sourceUrn, sinkId, presentationId, origin = undefined, tabId = undefined, + timeoutMillis = undefined, offTheRecord = false) { + const provider = this.getProviderFor_(sourceUrn, sinkId); + if (!provider) { + return Promise.reject(new mr.RouteRequestError( + mr.RouteRequestResultCode.NO_SUPPORTED_PROVIDER, + 'No provider supports createRoute with source: ' + sourceUrn + + ' and sink: ' + sinkId)); + } + let originString = undefined; + if (origin !== undefined) { + originString = this.mojoOriginToString_(origin); + } + timeoutMillis = mr.ProviderManager.getTimeoutToUse_( + mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS, timeoutMillis); + const routePromise = provider.createRoute( + sourceUrn, sinkId, presentationId, offTheRecord, timeoutMillis, + originString, tabId); + return this.addTimeout_(routePromise, timeoutMillis) + .then( + route => route, + /** Error */ err => { + this.logger_.error('Error creating route.', err); + throw err; + }); + } + + /** + * @override + */ + connectRouteByRouteId( + sourceUrn, routeId, presentationId, origin, tabId, + timeoutMillis = undefined) { + const provider = this.routeIdToProvider_.get(routeId); + if (!provider) { + return Promise.reject(new mr.RouteRequestError( + mr.RouteRequestResultCode.NO_SUPPORTED_PROVIDER, + 'No provider supports join ' + routeId)); + } + + // connectRouteByRouteId may take a while. Prevent extension suspension. + const routePromise = provider.connectRouteByRouteId( + sourceUrn, routeId, presentationId, this.mojoOriginToString_(origin), + tabId); + timeoutMillis = mr.ProviderManager.getTimeoutToUse_( + mr.ProviderManager.JOIN_ROUTE_TIMEOUT_MS_, timeoutMillis); + return this.addTimeout_(routePromise, timeoutMillis); + } + + /** + * @override + */ + joinRoute( + sourceUrn, presentationId, origin, tabId, timeoutMillis = undefined, + offTheRecord = false) { + const provider = this.getProviderForJoin_(sourceUrn, presentationId); + if (!provider) { + return Promise.reject(new mr.RouteRequestError( + mr.RouteRequestResultCode.NO_SUPPORTED_PROVIDER, + 'No provider supports join ' + presentationId)); + } + + // joinRoute may take a while. Prevent extension suspension. + timeoutMillis = mr.ProviderManager.getTimeoutToUse_( + mr.ProviderManager.JOIN_ROUTE_TIMEOUT_MS_, timeoutMillis); + const routePromise = provider.joinRoute( + sourceUrn, presentationId, offTheRecord, timeoutMillis, + this.mojoOriginToString_(origin), tabId); + return this.addTimeout_(routePromise, timeoutMillis); + } + + /** + * @override + */ + terminateRoute(routeId) { + const provider = this.routeIdToProvider_.get(routeId); + if (!provider) { + return Promise.reject(new mr.RouteRequestError( + mr.RouteRequestResultCode.ROUTE_NOT_FOUND, + 'Route not found for routeId ' + routeId)); + } + return this.maybeStopMirrorSession_(routeId).then( + () => provider.terminateRoute(routeId)); + } + + /** + * @override + */ + startObservingMediaSinks(sourceUrn) { + if (!this.sinkQueries_.has(sourceUrn)) { + this.sinkQueries_.add(sourceUrn); + this.providers_.forEach(p => { + p.startObservingMediaSinks(sourceUrn); + }); + } + this.querySinks_(sourceUrn); + } + + /** + * @override + */ + stopObservingMediaSinks(sourceUrn) { + if (!this.sinkQueries_.delete(sourceUrn)) { + this.logger_.info('No existing query ' + sourceUrn); + } else { + this.doStopObservingMediaSinks_(sourceUrn); + } + } + + /** + * Helper method to stop a sink query on all providers. + * @param {string} sourceUrn The URN of the media. + * @private + */ + doStopObservingMediaSinks_(sourceUrn) { + this.providers_.forEach(p => { + p.stopObservingMediaSinks(sourceUrn); + }); + } + + /** + * Queries for sinks capable of displaying |sourceUrn|. + * @param {string} sourceUrn The URN of the media. + * @private + */ + querySinks_(sourceUrn) { + + if (sourceUrn == 'urn:x-org.chromium.media:source:tab:-1') { + this.logger_.warning('No sinks for sourceUrn: ' + sourceUrn); + } else { + /** @type {!Map<string, !mr.Sink>} */ + const sinks = new Map(); + let origins = []; + // Get available sinks from current providers. + this.providers_.forEach(p => { + const sinkList = p.getAvailableSinks(sourceUrn); + if (sinkList.sinks.length > 0) { + origins = sinkList.origins; + } + sinkList.sinks.forEach(sink => { + // There shouldn't be duplicate sinks. Log a message if we encounter + // it. + if (sinks.has(sink.id)) { + this.logger_.warning( + 'Detected duplicate sink ' + sink.id + + ' from provider: ' + p.getName()); + } else { + sinks.set(sink.id, sink); + } + }); + }); + + this.sendSinksToMr_(sourceUrn, Array.from(sinks.values()), origins || []); + } + } + + /** + * Sends sinks that support |sourceUrn| to media router. + * @param {string} sourceUrn + * @param {!Array<!mr.Sink>} sinkList Sinks that support |sourceUrn| + * @param {!Array<string>} origins Origins that can access the sink list. + * @private + */ + sendSinksToMr_(sourceUrn, sinkList, origins) { + this.logger_.info( + 'Sending ' + sinkList.length + ' sinks to MR for ' + sourceUrn); + this.mediaRouterService_.onSinksReceived( + sourceUrn, sinkList, origins.map(mr.MojoUtils.stringToMojoOrigin)); + } + + /** + * @override + */ + sendRouteMessage(routeId, message, opt_extraInfo) { + const provider = this.routeIdToProvider_.get(routeId); + if (!provider) { + return Promise.reject(Error(`Invalid route ID ${routeId}`)); + } + return this.addIgnoredTimeout_( + provider.sendRouteMessage(routeId, message, opt_extraInfo), + mr.ProviderManager.SEND_MESSAGE_TIMEOUT_MS_); + } + + /** + * @override + */ + sendRouteBinaryMessage(routeId, data) { + const provider = this.routeIdToProvider_.get(routeId); + if (!provider) { + return Promise.reject(Error(`Invalid route ID ${routeId}`)); + } + return this.addIgnoredTimeout_( + provider.sendRouteBinaryMessage(routeId, data), + mr.ProviderManager.SEND_MESSAGE_TIMEOUT_MS_); + } + + /** + * @override + */ + startListeningForRouteMessages(routeId) { + this.mrRouteMessageSender_.listenForRouteMessages(routeId); + } + + /** + * @override + */ + stopListeningForRouteMessages(routeId) { + this.mrRouteMessageSender_.stopListeningForRouteMessages(routeId); + } + + /** + * @override + */ + detachRoute(routeId) { + const provider = this.routeIdToProvider_.get(routeId); + if (!provider) { + this.logger_.info('Route ' + routeId + ' does not exist.'); + return; + } + provider.detachRoute(routeId); + } + + /** + * @override + */ + enableMdnsDiscovery() { + this.mdnsEnabled_ = true; + this.mdnsEnabledCallbacks_.forEach(callback => { + callback(); + }); + this.mdnsEnabledCallbacks_.length = 0; + } + + /** + * @param {string} sourceUrn The URN of the media being displayed. + * @param {string} sinkId + * @return {?mr.Provider} The provider that can handle |sourceUrn| on |sinkId|. + * Null if none exists. + * @private + */ + getProviderFor_(sourceUrn, sinkId) { + return this.providers_.find( + provider => provider.canRoute(sourceUrn, sinkId)) || + null; + } + + /** + * @param {string} sourceUrn The URN of the media being displayed. + * @param {string} presentationId The presentation ID to join. + * @return {?mr.Provider} The provider that can join a route identified by + * |sourceUrn| and |presentationId|. Null if none exists. + * @private + */ + getProviderForJoin_(sourceUrn, presentationId) { + return this.providers_.find( + provider => provider.canJoin(sourceUrn, presentationId)) || + null; + } + + /** + * @private + */ + executeSinkQueries_() { + this.sinkQueries_.forEach(sourceUrn => { + this.querySinks_(sourceUrn); + }); + } + + /** + * @private + */ + maybeUpdateKeepAlive_() { + this.mediaRouterService_.setKeepAlive( + this.pendingRequestRoutes_ > 0 || this.keepAliveComponents_.length > 0); + } + + /** + * @override + */ + startObservingMediaRoutes(sourceUrn) { + if (!this.routeQueries_.has(sourceUrn)) { + this.routeQueries_.add(sourceUrn); + this.providers_.forEach(p => { + p.startObservingMediaRoutes(sourceUrn); + }); + } + this.routeUpdateEventThrottle_.fire(); + } + + /** + * @override + */ + stopObservingMediaRoutes(sourceUrn) { + if (!this.routeQueries_.delete(sourceUrn)) { + this.logger_.info('No existing route query ' + sourceUrn); + } else { + this.providers_.forEach(provider => { + provider.stopObservingMediaRoutes(sourceUrn); + }); + } + } + + /** + * Send routes query result to MR immediately. + * @private + */ + sendRoutesQueryResultToMr_() { + if (this.routeQueries_.size == 0) return; + + let routeList = []; + this.providers_.forEach(p => { + routeList = routeList.concat(p.getRoutes()); + }); + + this.routeQueries_.forEach(sourceUrn => { + const nonLocalJoinableRouteIds = []; + this.providers_.forEach(p => { + const routes = p.getRoutes(); + routes.forEach(route => { + if (!route.createdLocally && p.canJoin(sourceUrn, undefined, route)) { + nonLocalJoinableRouteIds.push(route.id); + } + }); + }); + this.mediaRouterService_.onRoutesUpdated( + routeList, sourceUrn, nonLocalJoinableRouteIds); + }); + } + + /** + * @override + */ + getStorageKey() { + return 'ProviderManager'; + } + + /** + * @override + */ + getData() { + return [new mr.ProviderManager.PersistentData_( + this.providers_.map(p => p.getName()), Array.from(this.sinkQueries_), + Array.from(this.routeQueries_), + Array.from( + this.routeIdToProvider_, + ([routeId, provider]) => [routeId, provider.getName()]), + Array.from(this.sinkAvailabilityMap_), this.mdnsEnabled_, + this.lastUsedMirrorService_)]; + } + + /** + * @override + */ + loadSavedData() { + const savedData = /** @type {mr.ProviderManager.PersistentData_} */ + (mr.PersistentDataManager.getTemporaryData(this)); + if (savedData) { + this.sinkQueries_ = new Set(savedData.sinkQueries); + this.routeQueries_ = new Set(savedData.routeQueries); + + for (let [routeId, providerName] of savedData.routeIdToProviderName) { + const provider = this.getProviderByName(providerName); + mr.Assertions.assert(provider, 'Provider not found: ' + providerName); + this.routeIdToProvider_.set(routeId, provider); + } + this.sinkAvailabilityMap_ = new Map(savedData.sinkAvailabilityMap); + this.lastUsedMirrorService_ = savedData.lastUsedMirrorService || null; + if (savedData.mdnsEnabled) { + this.enableMdnsDiscovery(); + } + } + } + + /** + * @override + */ + getProviderFromRouteId(routeId) { + return this.routeIdToProvider_.get(routeId); + } + + /** + * @override + */ + onRouteAdded(provider, route) { + this.routeIdToProvider_.set(route.id, provider); + this.onRouteUpdated(provider, route); + } + + /** + * @override + */ + onRouteRemoved(provider, route) { + // Stopping mirroring does not affect message delivery. + this.maybeStopMirrorSession_(route.id); + + this.routeIdToProvider_.delete(route.id); + this.mrRouteMessageSender_.onRouteRemoved(route.id); + this.onRouteUpdated(provider, route); + } + + /** + * @override + */ + onRouteUpdated(provider, route) { + if (this.routeQueries_.size > 0) this.routeUpdateEventThrottle_.fire(); + } + + /** + * @override + */ + handleMirrorActivityUpdate(route, mirrorActivity) { + const provider = this.routeIdToProvider_.get(route.id); + if (provider) { + provider.onMirrorActivityUpdated(route.id); + // Bypass the throttle, since an update to the route description may have + // happened before the throttle interval has elapsed. + if (this.routeQueries_.size > 0) this.sendRoutesQueryResultToMr_(); + } + } + + /** + * @override + */ + onRouteMessage(provider, routeId, message) { + if (!this.routeIdToProvider_.has(routeId)) { + this.logger_.warning('Got route message for closed route ' + routeId); + return; + } + // If message is not a string or an Uint8Array, encode it into a JSON string + // for mr.cast.InternalMessage. + if ((typeof message !== 'string') && !(message instanceof Uint8Array)) { + message = JSON.stringify(message); + } + this.mrRouteMessageSender_.send(routeId, message); + } + + /** + * @override + */ + onPresentationConnectionStateChanged(routeId, state) { + if (state == mr.PresentationConnectionState.TERMINATED) { + this.mrRouteMessageSender_.sendImmediately(); + } + this.mediaRouterService_.onPresentationConnectionStateChanged( + routeId, /** @type {string} */ (state)); + } + + /** + * @override + */ + onPresentationConnectionClosed(routeId, reason, message) { + this.mrRouteMessageSender_.sendImmediately(); + this.mediaRouterService_.onPresentationConnectionClosed( + routeId, /** @type {string} */ (reason), message); + } + + /** + * @override + */ + onMirrorSessionEnded(routeId) { + // When mirror service invokes this method, it already cleaned its session. + // So provider manager only needs to close the route via provider and does + // not + // need to invoke mirrorService.stopCurrentMirroring. + this.routeIdToMirrorServiceName_.delete(routeId); + const provider = this.routeIdToProvider_.get(routeId); + if (provider) { + provider.terminateRoute(routeId); + } + } + + /** + * @override + */ + onInternalMessage(provider, routeId, message) { + this.routeMessageEventTarget_.dispatchEvent( + new mr.InternalMessageEvent(routeId, message)); + } + + /** + * @override + */ + onSinksUpdated() { + this.sinkUpdateEventThrottle_.fire(); + } + + /** + * @override + */ + onSinkAvailabilityUpdated(provider, availability) { + const oldValue = this.sinkAvailabilityMap_.get(provider.getName()); + mr.Assertions.assert(oldValue !== undefined, 'oldValue != undefined'); + if (oldValue == availability) { + return; + } + + const currentAvailability = this.computeSinkAvailability_(); + this.sinkAvailabilityMap_.set(provider.getName(), availability); + const newAvailability = this.computeSinkAvailability_(); + if (currentAvailability == newAvailability) { + return; + } + + // When the overall SinkAvailability becomes UNAVAILABLE, all sink queries + // will be removed. Before that happens, we need to update the sink query + // results with empty results. Importantly, however, this also allows + // pseudo sinks to be sent as well, which clearing the sinks on the browser + // side would not maintain. + if (newAvailability == mr.SinkAvailability.UNAVAILABLE) { + this.executeSinkQueries_(); + } + this.mediaRouterService_.onSinkAvailabilityUpdated(newAvailability); + if (newAvailability == mr.SinkAvailability.UNAVAILABLE) { + this.sinkQueries_.forEach(sourceUrn => { + this.doStopObservingMediaSinks_(sourceUrn); + }); + this.sinkQueries_.clear(); + } + } + + /** + * @return {!mr.SinkAvailability} Overall sink availability based on providers' + * availability. + * @private + */ + computeSinkAvailability_() { + return Array.from(this.sinkAvailabilityMap_.values()) + .reduce( + (prev, cur) => Math.max(prev, cur), + mr.SinkAvailability.UNAVAILABLE); + } + + /** + * @override + */ + getRouteMessageEventTarget() { + return this.routeMessageEventTarget_; + } + + /** + * @override + */ + sendIssue(issue) { + this.mediaRouterService_.onIssue(issue); + } + + /** + * @param {string} routeId + * @return {!Promise<boolean>} Fulfilled with true if the route is for + * a mirror session and the mirroring was stopped, and with false + * otherwise. + * @private + */ + maybeStopMirrorSession_(routeId) { + if (this.routeIdToMirrorServiceName_.has(routeId)) { + // Stop mirroring first + return this + .getMirrorService(this.routeIdToMirrorServiceName_.get(routeId)) + .then(mirrorService => { + this.routeIdToMirrorServiceName_.delete(routeId); + return mirrorService.stopCurrentMirroring(); + }); + } + return Promise.resolve(false); + } + + /** + * @override + */ + startMirroring( + provider, route, opt_presentationId, opt_streamStartedCallback) { + // The provider can handle the mirroring URN, thus the mirror + // service name is non-null. + const mirrorServiceName = + /** @type {mr.mirror.ServiceName} */ (mr.Assertions.assertString( + provider.getMirrorServiceName(route.sinkId))); + this.logger_.info(`Starting mirroring using service: ${mirrorServiceName}`); + this.routeIdToMirrorServiceName_.set(route.id, mirrorServiceName); + let innerPromise = null; + let cancellationReason = null; + return mr.CancellablePromise.withUncancellableStep( + this.getMirrorService(mirrorServiceName), mirrorService => { + this.lastUsedMirrorService_ = mirrorServiceName; + return mirrorService + .startMirroring( + + route, mr.Assertions.assertString(route.mediaSource), + provider.getMirrorSettings(route.sinkId), opt_presentationId, + opt_streamStartedCallback) + .catch(err => { + if (err instanceof mr.mirror.Error && + err.reason == + mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL) { + throw new mr.RouteRequestError( + mr.RouteRequestResultCode.CANCELLED); + } + throw err; + }); + }); + } + + /** + * @override + */ + updateMirroring( + provider, route, sourceUrn, opt_presentationId, opt_tabId, + opt_streamStartedCallback) { + const mirrorServiceName = this.routeIdToMirrorServiceName_.get(route.id); + if (!mirrorServiceName) { + return mr.CancellablePromise.reject( + Error('Route ' + route.id + ' is not mirroring')); + } + return mr.CancellablePromise.withUncancellableStep( + this.getMirrorService(mirrorServiceName), mirrorService => { + this.lastUsedMirrorService_ = mirrorServiceName; + return mirrorService.updateMirroring( + route, sourceUrn, provider.getMirrorSettings(route.sinkId), + opt_presentationId, opt_tabId, opt_streamStartedCallback); + }); + } + + /** + * @override + */ + registerMdnsDiscoveryEnabledCallback(callback) { + if (this.mdnsEnabled_) { + callback(); + return; + } + if (this.mdnsEnabledCallbacks_.indexOf(callback) != -1) { + return; + } + this.mdnsEnabledCallbacks_.push(callback); + } + + /** + * @override + */ + isMdnsDiscoveryEnabled() { + return this.mdnsEnabled_; + } + + /** + * @override + */ + requestKeepAlive(componentId, keepAlive) { + const index = this.keepAliveComponents_.indexOf(componentId); + const componentKeepAlive = (index >= 0); + if (keepAlive && !componentKeepAlive) { + this.keepAliveComponents_.push(componentId); + } else if (!keepAlive && componentKeepAlive) { + this.keepAliveComponents_.splice(index, 1); + } + this.maybeUpdateKeepAlive_(); + } + + /** + * @param {!string} name + * @return {?mr.Provider} + */ + getProviderByName(name) { + return this.providers_.find(p => p.getName() == name) || null; + } + + /** + * Returns the name of the provider that created the pseudo sink ID |sinkId| + * or null if it is not a valid pseudo sink ID. + * @param {string} sinkId + * @return {?string} + * @private + */ + getProviderNameFromPseudoSinkId_(sinkId) { + if (!sinkId.startsWith(mr.ProviderManager.PSEUDO_SINK_NAME_PREFIX_)) { + return null; + } + return sinkId.substring(mr.ProviderManager.PSEUDO_SINK_NAME_PREFIX_.length); + } + + /** + * Get the rejection promise when sink is not found. + * @param {string} sinkId Sink ID of the pseudo sink that generated the + * request. + * @param {string} sourceUrn Source to be used with the sink. + * @param {!mr.SinkSearchCriteria} searchCriteria Sink search criteria for the + * MRP's which includes the user's current domain. + * @return {!Promise} + * @private + */ + rejectWithSinkNotFoundError_(sinkId, sourceUrn, searchCriteria) { + this.mediaRouterService_.onSearchSinkIdReceived(sinkId, ''); + return Promise.reject(new mr.RouteRequestError( + mr.RouteRequestResultCode.UNKNOWN_ERROR, + 'No sink found for search input: ' + searchCriteria.input + + ' and source: ' + sourceUrn)); + } + + /** + * @override + */ + searchSinks(sinkId, sourceUrn, searchCriteria) { + const providerName = this.getProviderNameFromPseudoSinkId_(sinkId); + const searchOwner = + providerName ? this.getProviderByName(providerName) : null; + if (!searchOwner) { + return Promise.reject(new Error('No provider supports ' + sinkId)); + } + return searchOwner.searchSinks(sourceUrn, searchCriteria) + .then((sink) => sink.id); + } + + /** + * @override + */ + provideSinks(providerName, sinks) { + const provider = this.getProviderByName(providerName); + if (!provider) { + this.logger_.error( + `provideSinks: Provider not found for providerName ${providerName}`); + return; + } + provider.provideSinks(sinks); + } + + /** + * @override + */ + updateMediaSinks(sourceUrn) { + // Don't add to sinkQueries as the providers may already be unavailable, but + // if the sink queries already contain the sourceUrn just return as + // discovery + // is already ongoing. + if (this.sinkQueries_.has(sourceUrn)) { + return; + } + this.querySinks_(sourceUrn); + } + + /** + * @override + */ + createMediaRouteController(routeId, controllerRequest, observer) { + const cleanUpOnError = () => { + controllerRequest.close(); + observer.ptr.reset(); + }; + const provider = this.routeIdToProvider_.get(routeId); + if (!provider) { + const errorMessage = + `createMediaRouteController: Provider not found for ${routeId}`; + this.logger_.error(errorMessage); + cleanUpOnError(); + return Promise.reject(new Error(errorMessage)); + } + return provider + .createMediaRouteController(routeId, controllerRequest, observer) + .catch(e => { + this.logger_.error(`createMediaRouteController failed: ${e.message}`); + cleanUpOnError(); + throw e; + }); + } + + /** + * @param {number} tabId + * @param {!mojo.MirrorServiceRemoterPtr} remoter + * @param {!mojo.InterfaceRequest} remotingSourceRequest + */ + onMediaRemoterCreated(tabId, remoter, remotingSourceRequest) { + this.mediaRouterService_.onMediaRemoterCreated( + tabId, remoter, remotingSourceRequest); + } + + /** + * @return {?mr.mirror.ServiceName} The name of most recently used mirror + * service, or null if mirror service has not been used. + */ + getLastUsedMirrorService() { + return this.lastUsedMirrorService_; + } + + /** + * Exports methods for Mojo handler. + * @param {!mr.ProviderManager} providerManager + * @private + */ + static exportProperties_(providerManager) { + // Cast to {!Object} so the compiler doesn't complain about accessing fields + // using subscript notation. + const obj = /** @type {!Object} */ (providerManager); + obj['onBeforeInvokeHandler'] = providerManager.onBeforeInvokeHandler; + obj['createRoute'] = providerManager.createRoute; + obj['joinRoute'] = providerManager.joinRoute; + obj['connectRouteByRouteId'] = providerManager.connectRouteByRouteId; + obj['terminateRoute'] = providerManager.terminateRoute; + obj['startObservingMediaSinks'] = providerManager.startObservingMediaSinks; + obj['stopObservingMediaSinks'] = providerManager.stopObservingMediaSinks; + obj['sendRouteMessage'] = providerManager.sendRouteMessage; + obj['sendRouteBinaryMessage'] = providerManager.sendRouteBinaryMessage; + obj['startListeningForRouteMessages'] = + providerManager.startListeningForRouteMessages; + obj['stopListeningForRouteMessages'] = + providerManager.stopListeningForRouteMessages; + obj['startObservingMediaRoutes'] = + providerManager.startObservingMediaRoutes; + obj['stopObservingMediaRoutes'] = providerManager.stopObservingMediaRoutes; + obj['detachRoute'] = providerManager.detachRoute; + obj['enableMdnsDiscovery'] = providerManager.enableMdnsDiscovery; + obj['searchSinks'] = providerManager.searchSinks; + obj['provideSinks'] = providerManager.provideSinks; + obj['updateMediaSinks'] = providerManager.updateMediaSinks; + obj['createMediaRouteController'] = + providerManager.createMediaRouteController; + } +}; + +/** + * Provider may fire sink/route list change event frequently. To avoid run all + * sink queries too frequently, only one set of queries at the end of each + * interval if there is an update event in the interval. + * @private @const {number} + */ +mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_ = 500; + +// The timeout values below are to ensure provider manager resolve or reject +// a pending MR request after a reasonable delay. This is a safe guard in case +// provider has some unforeseen error. + +/** @const {number} */ +mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS = 60 * 1000; + +/** @private @const {number} */ +mr.ProviderManager.JOIN_ROUTE_TIMEOUT_MS_ = 30 * 1000; + +/** @private @const {number} */ +mr.ProviderManager.SEND_MESSAGE_TIMEOUT_MS_ = 30 * 1000; + +/** @private @const {string} */ +mr.ProviderManager.PSEUDO_SINK_NAME_PREFIX_ = 'pseudo:'; + + +/** + * The Data to be saved in storage when event page suspends. + * @private + */ +mr.ProviderManager.PersistentData_ = class { + /** + * @param {!Array<string>} providerNames + * @param {!Array<string>} sinkQueries + * @param {!Array<string>} routeQueries + * @param {!Array<!Array>} routeIdToProviderName + * @param {!Array<!Array>} sinkAvailabilityMap + * @param {boolean} mdnsEnabled + * @param {?mr.mirror.ServiceName} lastUsedMirrorService + */ + constructor( + providerNames, sinkQueries, routeQueries, routeIdToProviderName, + sinkAvailabilityMap, mdnsEnabled, lastUsedMirrorService) { + /** + * @type {!Array<string>} + */ + this.providerNames = providerNames; + + /** + * @type {!Array<string>} + */ + this.sinkQueries = sinkQueries; + + /** + * @type {!Array<string>} + */ + this.routeQueries = routeQueries; + + /** + * Map encoded as an array. + * @type {!Array<!Array>} + */ + this.routeIdToProviderName = routeIdToProviderName; + + /** + * Map encoded as an array. + * @type {!Array<!Array>} + */ + this.sinkAvailabilityMap = sinkAvailabilityMap; + + /** + * @type {boolean} + */ + this.mdnsEnabled = mdnsEnabled; + + /** + * @type {?mr.mirror.ServiceName} + */ + this.lastUsedMirrorService = lastUsedMirrorService; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager_callbacks.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager_callbacks.js new file mode 100644 index 00000000000..01ff1f5d3fa --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager_callbacks.js @@ -0,0 +1,223 @@ +// 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. + +/** + * @fileoverview API used by Media Route Providers to interact with the Media + * Route Provider Manager. + */ + +goog.provide('mr.ProviderManagerCallbacks'); +goog.provide('mr.ProviderManagerIssueCallbacks'); +goog.provide('mr.ProviderManagerMirrorServiceCallbacks'); +goog.provide('mr.ProviderManagerRouteCallbacks'); +goog.provide('mr.ProviderManagerSinkCallbacks'); + +goog.require('mr.EventTarget'); +goog.require('mr.mirror.Activity'); + + +/** + * @record + */ +mr.ProviderManagerSinkCallbacks = class { + /** + * Called to notify that sinks have been added, removed, or updated. + */ + onSinksUpdated() {} + + /** + * Called to notify that sink availability has changed. + * + + * + * @param {!mr.Provider} provider + * @param {!mr.SinkAvailability} availability + */ + onSinkAvailabilityUpdated(provider, availability) {} +}; + + + +/** + * @record + */ +mr.ProviderManagerRouteCallbacks = class { + /** + * @param {!mr.Provider} provider + * @param {!mr.Route} route + */ + onRouteAdded(provider, route) {} + + /** + * @param {!mr.Provider} provider + * @param {!mr.Route} route + */ + onRouteRemoved(provider, route) {} + + /** + * @param {!mr.Provider} provider + * @param {!mr.Route} route + */ + onRouteUpdated(provider, route) {} + + /** + * Sends the provided message to the web app. If the message should also be + * sent internally via the MessagePort, also call onInternalMessage. + * @param {!mr.Provider} provider + * @param {string} routeId + * @param {string|!Uint8Array|!Object} message If not a string (text message) + * or Uint8Array (binary message), then the message will be serialized to + * a JSON string and sent as a text message. + */ + onRouteMessage(provider, routeId, message) {} + + /** + * Called to notify the presentation connected to a route has changed state. + * If the state is CLOSED, onPresentationConnectionClosed is called instead of + * this method. + * @param {!string} routeId + * @param {!mr.PresentationConnectionState} state + */ + onPresentationConnectionStateChanged(routeId, state) {} + + /** + * Called to notify the presentation connected to a route has closed. + * @param {string} routeId + * @param {!mr.PresentationConnectionCloseReason} reason + * @param {string} message + */ + onPresentationConnectionClosed(routeId, reason, message) {} +}; + + + +/** + * @record + */ +mr.ProviderManagerIssueCallbacks = class { + /** + * Sends the given issue to MediaRouter. + * @param {!mr.Issue} issue + */ + sendIssue(issue) {} +}; + + + +/** + * @record + * @extends {mr.ProviderManagerIssueCallbacks} + */ +mr.ProviderManagerMirrorServiceCallbacks = class { + /** + * Invoked by mirror service when it stops a mirror session due to error, + * the end of a stream etc. Because provider manager is unaware of mirror + * session's internal state, this method is used by mirror service to tell + * provider manager to close the corresponding route. + * @param {string} routeId + */ + onMirrorSessionEnded(routeId) {} + + /** + * Invoked by the mirror service when the mirroring activity description for + * mirroring route |route| has changed. + * + * @param {!mr.Route} route + * @param {!mr.mirror.Activity} mirrorActivity + */ + handleMirrorActivityUpdate(route, mirrorActivity) {} +}; + + + +/** + * @record + * @extends {mr.ProviderManagerIssueCallbacks} + * @extends {mr.ProviderManagerSinkCallbacks} + * @extends {mr.ProviderManagerRouteCallbacks} + * @extends {mr.ProviderManagerMirrorServiceCallbacks} + */ +mr.ProviderManagerCallbacks = class { + /** + * @param {string} routeId + * @return {mr.Provider} + */ + getProviderFromRouteId(routeId) {} + + /** + * @return {!mr.EventTarget} + */ + getRouteMessageEventTarget() {} + + /** + * Sends the message internally, to the mirror session associated with the + * provided route ID, via the MessagePort. + * @param {!mr.Provider} provider + * @param {!string} routeId + * @param {!Object|string} message + */ + onInternalMessage(provider, routeId, message) {} + + /** + * Called by a provider to request the mirroring service to start mirroring. + * @param {!mr.Provider} provider + * @param {!mr.Route} route + * @param {string=} opt_presentationId + * @param {(function(!mr.Route): !mr.CancellablePromise<!mr.Route>)=} + * opt_streamStartedCallback Callback to invoke after stream capture + * succeeded and before the mirror session is created. This allows the + * provider to perform additional setup and update the route for the + * mirror session. + * @return {!mr.CancellablePromise<!mr.Route>} + */ + startMirroring( + provider, route, opt_presentationId, opt_streamStartedCallback) {} + + /** + * Called by a provider to request the mirroring service to update |route| to + * the new source |sourceUrn|. + * @param {!mr.Provider} provider + * @param {!mr.Route} route + * @param {string} sourceUrn + * @param {string=} opt_presentationId + * @param {number=} opt_tabId + * @param {(function(!mr.Route): !mr.CancellablePromise)=} + * opt_streamStartedCallback Callback to invoke after stream capture + * succeeded and before the mirror session is created. This allows the + * provider to perform additional setup and update the route for the + * mirror session. + * @return {!mr.CancellablePromise<!mr.Route>} + */ + updateMirroring( + provider, route, sourceUrn, opt_presentationId, opt_tabId, + opt_streamStartedCallback) {} + + /** + * Register a callback with the provider manager that will either be executed + * immediately if mDNS discovery is currently enabled or saved to be executed + * when mDNS discovery becomes enabled. This should allow duplicate calls to + * be + * made with the same function address but only call the function once. + * @param {function()} callback Callback that depends on mDNS discovery. + */ + registerMdnsDiscoveryEnabledCallback(callback) {} + + /** + * Called by a component with |keepAlive| set to true when it requires the + * extension to be kept alive. When a component no longer requires the + * extension + * to be kept alive, this method should be called with |keepAlive| set to + * false. + * The extension will be prevented from suspending as long as at least one + * component requested keep alive. + * @param {string} componentId Globally unique id of the requesting component. + * @param {boolean} keepAlive + */ + requestKeepAlive(componentId, keepAlive) {} + + /** + * @return {boolean} Whether mDNS discovery is currently enabled. + */ + isMdnsDiscoveryEnabled() {} +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager_test.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager_test.js new file mode 100644 index 00000000000..9453ed678b2 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/provider_manager_test.js @@ -0,0 +1,1076 @@ +/** + * @fileoverview Tests for provider_manager. + */ +goog.setTestOnly('provider_manager_test'); + +goog.require('mr.CancellablePromise'); +goog.require('mr.MediaSourceUtils'); +goog.require('mr.MirrorAnalytics'); +goog.require('mr.Module'); +goog.require('mr.PersistentDataManager'); +goog.require('mr.PresentationConnectionCloseReason'); +goog.require('mr.PresentationConnectionState'); +goog.require('mr.ProviderManager'); +goog.require('mr.Route'); +goog.require('mr.RouteMessage'); +goog.require('mr.RouteRequestError'); +goog.require('mr.RouteRequestResultCode'); +goog.require('mr.Sink'); +goog.require('mr.SinkAvailability'); +goog.require('mr.SinkList'); +goog.require('mr.UnitTestUtils'); +goog.require('mr.mirror.Error'); +goog.require('mr.mirror.ServiceName'); + +describe('Tests ProviderManager', function() { + let sourceUrn; + let mockMediaRouterService; + let providerManager; + let mockProvider1; + let mockProvider1Name; + let mockProvider2; + let mockBrokenProvider; + let mockCastMirrorService; + let mockWebrtcMirrorService; + let mirrorServiceMap; + const provider1Routes = []; + const provider2Routes = []; + const providerMethods = [ + 'getName', 'initialize', 'getAvailableSinks', 'createRoute', + 'terminateRoute', 'sendRouteMessage', 'getSinkById', 'getMirrorSettings', + 'getMirrorServiceName', 'canRoute', 'startObservingMediaSinks', + 'stopObservingMediaSinks', 'startObservingMediaRoutes', + 'stopObservingMediaRoutes', 'getRoutes', 'canJoin', 'searchSinks', + 'createMediaRouteController' + ]; + const mirrorServiceMethods = [ + 'initialize', + 'getName', + 'startMirroring', + 'stopCurrentMirroring', + 'createMirrorSession', + 'updateMirroring', + ]; + const castMirrorServiceMethods = mirrorServiceMethods.slice(); + const presentationId = 'presentationId'; + const routeId = '123'; + + let mockClock; + + beforeEach(function() { + mr.PersistentDataManager.clear(); + mr.Module.clearForTest(); + mr.UnitTestUtils.mockMojoApi(); + sourceUrn = 'urn:dial-multiscreen-org:dial:application:YouTube'; + mockClock = null; + window['chrome'] = chrome || {}; + chrome['runtime'] = chrome.runtime || {}; + chrome.runtime.id = '123'; + chrome.runtime.getManifest = function() { + return {version: 'fakeVersion'}; + }; + mockProvider1 = jasmine.createSpyObj('provider1', providerMethods); + mockProvider1Name = 'p1'; + mockProvider1.getName.and.returnValue(mockProvider1Name); + mockProvider1.getRoutes.and.callFake(() => provider1Routes); + mockProvider2 = jasmine.createSpyObj('provider2', providerMethods); + mockProvider2.getName.and.returnValue('p2'); + mockProvider2.getRoutes.and.callFake(() => provider2Routes); + mockBrokenProvider = + jasmine.createSpyObj('brokenProvider', providerMethods); + mockBrokenProvider.initialize.and.throwError( + new Error('I forgot how to initialize. @_@')); + mockCastMirrorService = + jasmine.createSpyObj('castMirrorService', castMirrorServiceMethods); + mockCastMirrorService.getName.and.returnValue( + mr.mirror.ServiceName.CAST_STREAMING); + mockWebrtcMirrorService = + jasmine.createSpyObj('webrtcMirrorService', mirrorServiceMethods); + mockWebrtcMirrorService.getName.and.returnValue( + mr.mirror.ServiceName.WEBRTC); + mirrorServiceMap = new Map(); + mirrorServiceMap.set( + mr.mirror.ServiceName.CAST_STREAMING, mockCastMirrorService); + mirrorServiceMap.set(mr.mirror.ServiceName.WEBRTC, mockWebrtcMirrorService); + + // These two not needed? + spyOn(mr.mirror.cast, 'Service').and.returnValue(mockCastMirrorService); + spyOn(mr.mirror.webrtc, 'WebRtcService') + .and.returnValue(mockWebrtcMirrorService); + mockMediaRouterService = jasmine.createSpyObj('mrService', [ + 'setKeepAlive', 'getKeepAlive', 'setHandlers', + 'onPresentationConnectionClosed', 'onPresentationConnectionStateChanged', + 'onRoutesUpdated', 'onSinkAvailabilityUpdated', 'onSinksReceived', + 'start', 'onSearchSinkIdReceived', 'onRouteMessagesReceived' + ]); + const mockCloudComponentProvider = { + getIdentityService: function() { + return {}; + } + }; + spyOn(mr.cloud.CloudComponentProvider, 'getInstance') + .and.returnValue(mockCloudComponentProvider); + providerManager = new mr.ProviderManager(); + expect(mockMediaRouterService.start.calls.count()).toBe(0); + providerManager.initialize(mockMediaRouterService, []); + mockMediaRouterService.start.and.returnValue('instance123'); + spyOn(providerManager.mrRouteMessageSender_, 'send').and.callThrough(); + spyOn(providerManager, 'getMirrorService').and.callFake(serviceName => { + return Promise.resolve(mirrorServiceMap.get(serviceName)); + }); + }); + + afterEach(function() { + if (mockClock) { + mr.UnitTestUtils.restoreRealClockAndPromises(); + } + }); + + describe('Test presentation connection state changes', function() { + it('onPresentationConnectionStateChanged', function() { + const state = mr.PresentationConnectionState.TERMINATED; + providerManager.onPresentationConnectionStateChanged(routeId, state); + expect(mockMediaRouterService.onPresentationConnectionStateChanged) + .toHaveBeenCalledWith(routeId, state); + }); + + it('onPresentationConnectionStateClosed', function() { + const closeReason = mr.PresentationConnectionCloseReason.WENT_AWAY; + const message = 'Connection went away'; + providerManager.onPresentationConnectionClosed( + routeId, closeReason, message); + expect(mockMediaRouterService.onPresentationConnectionClosed) + .toHaveBeenCalledWith(routeId, closeReason, message); + }); + }); + + describe('Test registerAllProviders', function() { + it('Provider is initialized', function() { + providerManager.registerAllProviders( + [mockProvider1, mockProvider2, mockBrokenProvider]); + expect(mockProvider1.initialize.calls.count()).toBe(1); + expect(mockProvider2.initialize.calls.count()).toBe(1); + expect(mockBrokenProvider.initialize.calls.count()).toBe(1); + expect(providerManager.getProviders().some( + p => p.getName() == mockProvider1.getName())) + .toBe(true); + expect(providerManager.getProviders().some( + p => p.getName() == mockProvider2.getName())) + .toBe(true); + expect(!providerManager.getProviders().some( + p => p.getName() == mockBrokenProvider.getName())) + .toBe(true); + }); + + it('Route message event is listened to', function() { + mockClock = mr.UnitTestUtils.useMockClockAndPromises(); + const route = new mr.Route(routeId, 'pId', '0', null, false, '', null); + const message = 'msg'; + providerManager.registerAllProviders([mockProvider1]); + providerManager.onRouteAdded(mockProvider1, route); + providerManager.onRouteMessage(mockProvider1, routeId, message, true); + providerManager.startListeningForRouteMessages(routeId); + mockClock.tick(mr.RouteMessageSender.SEND_MESSAGE_INTERVAL_MILLIS); + expect(mockMediaRouterService.onRouteMessagesReceived) + .toHaveBeenCalledWith( + routeId, [new mr.RouteMessage(routeId, message)]); + }); + + it('Message of a closed route is not forwarded', function() { + const message = 'msg'; + providerManager.registerAllProviders([mockProvider1]); + providerManager.onRouteMessage(mockProvider1, routeId, message); + expect(providerManager.mrRouteMessageSender_.send).not.toHaveBeenCalled(); + }); + + it('InternalMessage sends and is not forwarded to app', function() { + const route = new mr.Route(routeId, 'pId', '0', null, false, '', null); + const message = 'msg'; + providerManager.registerAllProviders([mockProvider1]); + providerManager.onRouteAdded(mockProvider1, route); + providerManager.onInternalMessage(mockProvider1, routeId, message); + expect(providerManager.mrRouteMessageSender_.send).not.toHaveBeenCalled(); + }); + + it('start/stopObservingMediaRoutes is passed to provider', function() { + mockProvider1.getRoutes.and.returnValue([]); + mockProvider2.getRoutes.and.returnValue([]); + providerManager.registerAllProviders([mockProvider1, mockProvider2]); + + expect(mockProvider1.startObservingMediaRoutes.calls.count()).toBe(0); + expect(mockProvider2.startObservingMediaRoutes.calls.count()).toBe(0); + expect(mockProvider1.stopObservingMediaRoutes.calls.count()).toBe(0); + expect(mockProvider2.stopObservingMediaRoutes.calls.count()).toBe(0); + + providerManager.startObservingMediaRoutes(sourceUrn); + expect(mockProvider1.startObservingMediaRoutes.calls.count()).toBe(1); + expect(mockProvider2.startObservingMediaRoutes.calls.count()).toBe(1); + expect(mockProvider1.stopObservingMediaRoutes.calls.count()).toBe(0); + expect(mockProvider2.stopObservingMediaRoutes.calls.count()).toBe(0); + + providerManager.stopObservingMediaRoutes(sourceUrn); + expect(mockProvider1.startObservingMediaRoutes.calls.count()).toBe(1); + expect(mockProvider2.startObservingMediaRoutes.calls.count()).toBe(1); + expect(mockProvider1.stopObservingMediaRoutes.calls.count()).toBe(1); + expect(mockProvider2.stopObservingMediaRoutes.calls.count()).toBe(1); + }); + + it('Routes query result is sent back to MR initially', function() { + providerManager.startObservingMediaRoutes(sourceUrn); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + providerManager.stopObservingMediaRoutes(sourceUrn); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + }); + + it('Routes query result is sent back to MR on events', function() { + mockClock = mr.UnitTestUtils.useMockClockAndPromises(); + + const route = new mr.Route(routeId, 'pId', '0', null, false, '', null); + + providerManager.startObservingMediaRoutes(sourceUrn); + // Send routes right away + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + + // Call to MR is throttled. + mockClock.tick(mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_ / 2); + providerManager.onRouteAdded(mockProvider1, route); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + providerManager.onRouteRemoved(mockProvider1, route); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + providerManager.onRouteAdded(mockProvider1, route); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + mockClock.tick(mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_ + 1); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(2); + }); + + it('Routes query result is not sent back to MR if stopped', function() { + mockClock = mr.UnitTestUtils.useMockClockAndPromises(); + + const route = new mr.Route(routeId, 'pId', '0', null, false, '', null); + + providerManager.startObservingMediaRoutes(sourceUrn); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + + mockClock.tick(mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_ / 2); + providerManager.onRouteAdded(mockProvider1, route); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + providerManager.onRouteRemoved(mockProvider1, route); + providerManager.onRouteAdded(mockProvider1, route); + providerManager.stopObservingMediaRoutes(sourceUrn); + // Query was stopped to MR is not called. + mockClock.tick(mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_ / 2 + 1); + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + + }); + + it('Multiple route queries do not cause duplicate routes', function() { + mockClock = mr.UnitTestUtils.useMockClockAndPromises(); + + const route = new mr.Route(routeId, 'pId', '0', null, false, '', null); + const sourceUrn2 = ''; + // Only one route will be returned. + mockProvider1.getRoutes.and.returnValue([route]); + mockProvider1.canJoin.and.returnValue(false); + providerManager.registerAllProviders([mockProvider1]); + + // Multiple route queries means the one route should be returned for + // multiple route queries. + providerManager.startObservingMediaRoutes(sourceUrn); + providerManager.startObservingMediaRoutes(sourceUrn2); + // Call to MR is throttled - so this will only happen once. + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(1); + expect(mockMediaRouterService.onRoutesUpdated) + .toHaveBeenCalledWith([route], sourceUrn, []); + mockClock.tick(mr.ProviderManager.ALL_QUERIES_INTERVAL_MS_ + 1); + // This will fire twice because there are two route queries, bringing the + // total to 3 times. + expect(mockMediaRouterService.onRoutesUpdated.calls.count()).toBe(3); + expect(mockMediaRouterService.onRoutesUpdated) + .toHaveBeenCalledWith([route], sourceUrn, []); + expect(mockMediaRouterService.onRoutesUpdated) + .toHaveBeenCalledWith([route], sourceUrn2, []); + }); + }); + + describe('Test keep alive', function() { + it('Keep alive for 1 provider', function() { + providerManager.requestKeepAlive(mockProvider1, true); + expect(mockMediaRouterService.setKeepAlive).toHaveBeenCalledWith(true); + providerManager.requestKeepAlive(mockProvider1, false); + expect(mockMediaRouterService.setKeepAlive).toHaveBeenCalledWith(false); + }); + + it('Keep alive for more than 1 provider', function() { + providerManager.requestKeepAlive(mockProvider1, true); + expect(mockMediaRouterService.setKeepAlive).toHaveBeenCalledWith(true); + providerManager.requestKeepAlive(mockProvider2, true); + expect(mockMediaRouterService.setKeepAlive).toHaveBeenCalledWith(true); + providerManager.requestKeepAlive(mockProvider1, false); + expect(mockMediaRouterService.setKeepAlive).toHaveBeenCalledWith(true); + providerManager.requestKeepAlive(mockProvider2, false); + expect(mockMediaRouterService.setKeepAlive).toHaveBeenCalledWith(false); + }); + }); + + describe('Test createRoute', function() { + let sinkId; + let localRoute; + + beforeEach(function() { + sourceUrn = 'urn:dial-multiscreen-org:dial:application:YouTube'; + sinkId = 'sink1'; + localRoute = new mr.Route('r2', 'pId', sinkId, sourceUrn, true, '', null); + providerManager.registerAllProviders([mockProvider1, mockProvider2]); + }); + + it('No provider can handle it', function(done) { + mockProvider1.canRoute.and.returnValue(false); + mockProvider2.canRoute.and.returnValue(false); + providerManager.createRoute(sourceUrn, sinkId, presentationId) + .catch(e => { + expect(e instanceof mr.RouteRequestError).toBe(true); + expect(e.errorCode) + .toEqual(mr.RouteRequestResultCode.NO_SUPPORTED_PROVIDER); + expect(mockProvider1.canRoute) + .toHaveBeenCalledWith(sourceUrn, sinkId); + expect(mockProvider2.canRoute) + .toHaveBeenCalledWith(sourceUrn, sinkId); + + expect(mockMediaRouterService.setKeepAlive.calls.count()).toBe(0); + + done(); + }); + }); + + it('One provider can handle, but fail to create session', function(done) { + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + mockProvider1.createRoute.and.returnValue( + mr.CancellablePromise.reject(Error('err'))); + providerManager.createRoute(sourceUrn, sinkId, presentationId) + .catch(e => { + expect(e instanceof mr.RouteRequestError).toBe(true); + expect(e.errorCode) + .toEqual(mr.RouteRequestResultCode.UNKNOWN_ERROR); + expect(mockProvider1.createRoute.calls.count()).toBe(1); + expect(mockProvider1.createRoute) + .toHaveBeenCalledWith( + sourceUrn, sinkId, presentationId, false, + mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS, undefined, + undefined); + expect(mockProvider2.createRoute.calls.count()).toBe(0); + + expect(mockMediaRouterService.setKeepAlive.calls.argsFor(0)) + .toEqual([true]); + expect(mockMediaRouterService.setKeepAlive.calls.argsFor(1)) + .toEqual([false]); + + done(); + }); + }); + + it('One provider can handle it, and created session', function(done) { + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + mockProvider1.createRoute.and.callFake(() => { + providerManager.onRouteAdded(mockProvider1, localRoute); + return mr.CancellablePromise.resolve(localRoute); + }); + providerManager.createRoute(sourceUrn, sinkId, presentationId).then(r => { + expect(r).toEqual(localRoute); + expect(mockProvider1.createRoute.calls.count()).toBe(1); + expect(mockProvider1.createRoute) + .toHaveBeenCalledWith( + sourceUrn, sinkId, presentationId, false, + mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS, undefined, + undefined); + expect(mockProvider2.createRoute.calls.count()).toBe(0); + + done(); + }); + }); + + it('createRoute with custom timeout', function(done) { + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + mockProvider1.createRoute.and.callFake(() => { + providerManager.onRouteAdded(mockProvider1, localRoute); + return mr.CancellablePromise.resolve(localRoute); + }); + const timeoutMillis = 12345; + providerManager + .createRoute( + sourceUrn, sinkId, presentationId, undefined, undefined, + timeoutMillis) + .then(r => { + expect(r).toEqual(localRoute); + expect(mockProvider1.createRoute.calls.count()).toBe(1); + expect(mockProvider1.createRoute) + .toHaveBeenCalledWith( + sourceUrn, sinkId, presentationId, false, timeoutMillis, + undefined, undefined); + expect(mockProvider2.createRoute.calls.count()).toBe(0); + + done(); + }); + }); + + it('a mirror session is created', function(done) { + sourceUrn = mr.MediaSourceUtils.DESKTOP_MIRROR_URN; + localRoute = new mr.Route('r2', 'pId', sinkId, sourceUrn, true, '', null); + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + const mirrorSettings = {}; + mockProvider1.getMirrorSettings.and.returnValue(mirrorSettings); + mockProvider1.getMirrorServiceName.and.returnValue( + mr.mirror.ServiceName.CAST_STREAMING); + mockProvider1.createRoute.and.callFake(() => { + providerManager.onRouteAdded(this, localRoute); + return mr.CancellablePromise.resolve(localRoute); + }); + mockCastMirrorService.startMirroring.and.returnValue( + mr.CancellablePromise.resolve(localRoute)); + expect(providerManager.getLastUsedMirrorService()).toBeNull(); + providerManager.createRoute(sourceUrn, sinkId, presentationId) + .then(route => { + providerManager.startMirroring(mockProvider1, route, presentationId) + .promise.then(r => { + expect(mockProvider1.createRoute.calls.count()).toBe(1); + expect(mockProvider1.createRoute) + .toHaveBeenCalledWith( + sourceUrn, sinkId, presentationId, false, + mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS, undefined, + undefined); + expect(mockProvider2.createRoute.calls.count()).toBe(0); + expect(mockCastMirrorService.startMirroring.calls.count()) + .toBe(1); + expect(mockCastMirrorService.startMirroring) + .toHaveBeenCalledWith( + localRoute, sourceUrn, mirrorSettings, presentationId, + undefined); + expect(providerManager.routeIdToProvider_.has(localRoute.id)) + .toBe(true); + expect(providerManager.routeIdToMirrorServiceName_.has( + localRoute.id)) + .toBe(true); + expect(providerManager.getLastUsedMirrorService()) + .toBe(mr.mirror.ServiceName.CAST_STREAMING); + done(); + }); + }); + }); + + it('a mirror session is not created', function(done) { + sourceUrn = mr.MediaSourceUtils.DESKTOP_MIRROR_URN; + localRoute = new mr.Route('r2', 'pId', sinkId, sourceUrn, true, '', null); + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + const mirrorSettings = {}; + mockProvider1.getMirrorSettings.and.returnValue(mirrorSettings); + mockProvider1.getMirrorServiceName.and.returnValue( + mr.mirror.ServiceName.CAST_STREAMING); + mockProvider1.createRoute.and.callFake(() => { + providerManager.onRouteAdded(mockProvider1, localRoute); + return mr.CancellablePromise.resolve(localRoute); + }); + const error = Error('failed to mirror'); + mockCastMirrorService.startMirroring.and.callFake( + () => mr.CancellablePromise.reject(error)); + + providerManager.createRoute(sourceUrn, sinkId, presentationId) + .then(route => { + providerManager.startMirroring(mockProvider1, route, presentationId) + .promise.catch(err => { + expect(err).toEqual(error); + expect(mockProvider1.createRoute.calls.count()).toBe(1); + expect(mockProvider1.createRoute) + .toHaveBeenCalledWith( + sourceUrn, sinkId, presentationId, false, + mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS, undefined, + undefined); + expect(mockProvider2.createRoute.calls.count()).toBe(0); + expect(mockCastMirrorService.startMirroring.calls.count()) + .toBe(1); + expect(mockCastMirrorService.startMirroring) + .toHaveBeenCalledWith( + localRoute, sourceUrn, mirrorSettings, presentationId, + undefined); + // Call onMirrorSessionEnded as the mirror service would. + + providerManager.onMirrorSessionEnded(route.id); + // Call onRouteRemoved as the provider would. + + providerManager.onRouteRemoved(mockProvider1, route); + expect(providerManager.routeIdToProvider_.has(localRoute.id)) + .toBe(false); + expect(providerManager.routeIdToMirrorServiceName_.has( + localRoute.id)) + .toBe(false); + + done(); + }); + }); + }); + + it('an mr.RouteRequestError is returned for user cancellation', (done) => { + mockProvider1.canRoute.and.returnValue(true); + mockProvider1.getMirrorSettings.and.returnValue({}); + mockProvider1.getMirrorServiceName.and.returnValue( + mr.mirror.ServiceName.CAST_STREAMING); + const error = new mr.mirror.Error( + '', + mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL); + mockCastMirrorService.startMirroring.and.callFake( + () => mr.CancellablePromise.reject(error)); + + providerManager + .startMirroring( + mockProvider1, + new mr.Route( + 'r2', 'pId', sinkId, mr.MediaSourceUtils.DESKTOP_MIRROR_URN, + true, '', null), + presentationId) + .promise.catch(err => { + expect(err instanceof mr.RouteRequestError).toBe(true); + expect(err.errorCode).toBe(mr.RouteRequestResultCode.CANCELLED); + done(); + }); + }); + + it('createRoute with default timeout', function(done) { + mockClock = mr.UnitTestUtils.useMockClockAndPromises(); + + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + // The provider will never resolve the promise. Provider manager will + // reject it on timeout. + mockProvider1.createRoute.and.returnValue( + new mr.CancellablePromise(() => {})); + providerManager.createRoute(sourceUrn, sinkId, presentationId) + .then( + r => { + fail('Route unexpectedly created'); + }, + e => { + expect(e instanceof mr.RouteRequestError).toBe(true); + expect(e.errorCode) + .toEqual(mr.RouteRequestResultCode.TIMED_OUT); + expect(e.message).toMatch('timeout'); + done(); + }); + mockClock.tick(mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS + 1); + }); + + it('createRoute with custom timeout', function(done) { + mockClock = mr.UnitTestUtils.useMockClockAndPromises(); + + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + // The provider will never resolve the promise. Provider manager will + // reject it on timeout. + mockProvider1.createRoute.and.returnValue( + new mr.CancellablePromise(() => {})); + const timeoutMillis = 20 * 1000; + providerManager + .createRoute( + sourceUrn, sinkId, 'presentationId', 'origin', 0, timeoutMillis) + .then( + r => { + fail('Route unexpectedly created'); + }, + e => { + expect(e instanceof mr.RouteRequestError).toBe(true); + expect(e.errorCode) + .toEqual(mr.RouteRequestResultCode.TIMED_OUT); + expect(e.message).toMatch('timeout'); + done(); + }); + mockClock.tick(timeoutMillis + 1); + }); + + it('update mirror session', function(done) { + localRoute = new mr.Route('r2', 'pId', sinkId, sourceUrn, true, '', null); + mockProvider1.canRoute.and.returnValue(true); + mockProvider2.canRoute.and.returnValue(false); + const mirrorSettings = {}; + mockProvider1.getMirrorSettings.and.returnValue(mirrorSettings); + mockProvider1.getMirrorServiceName.and.returnValue( + mr.mirror.ServiceName.CAST_STREAMING); + mockProvider1.createRoute.and.callFake(() => { + providerManager.onRouteAdded(this, localRoute); + return mr.CancellablePromise.resolve(localRoute); + }); + mockCastMirrorService.startMirroring.and.returnValue( + mr.CancellablePromise.resolve(localRoute)); + mockCastMirrorService.updateMirroring.and.returnValue( + mr.CancellablePromise.resolve(localRoute)); + providerManager.createRoute(sourceUrn, sinkId, presentationId) + .then(route => { + providerManager.startMirroring(mockProvider1, route, presentationId) + .promise.then(r => { + expect(mockProvider1.createRoute.calls.count()).toBe(1); + expect(mockProvider1.createRoute) + .toHaveBeenCalledWith( + sourceUrn, sinkId, presentationId, false, + mr.ProviderManager.CREATE_ROUTE_TIMEOUT_MS, undefined, + undefined); + expect(mockProvider2.createRoute.calls.count()).toBe(0); + expect(mockCastMirrorService.startMirroring.calls.count()) + .toBe(1); + expect(mockCastMirrorService.startMirroring) + .toHaveBeenCalledWith( + localRoute, sourceUrn, mirrorSettings, presentationId, + undefined); + expect(providerManager.routeIdToProvider_.has(localRoute.id)) + .toBe(true); + expect(providerManager.routeIdToMirrorServiceName_.has( + localRoute.id)) + .toBe(true); + + const newSourceUrn = mr.MediaSourceUtils.DESKTOP_MIRROR_URN; + const callback = function() {}; + providerManager + .updateMirroring( + mockProvider1, r, newSourceUrn, presentationId, + undefined, callback) + .promise.then(updatedRoute => { + expect( + mockCastMirrorService.updateMirroring.calls.count()) + .toBe(1); + expect(mockCastMirrorService.updateMirroring) + .toHaveBeenCalledWith( + r, newSourceUrn, mirrorSettings, presentationId, + undefined, callback); + done(); + }); + }); + }); + }); + + it('updateMirroring before startMirroring fails', function(done) { + localRoute = new mr.Route('r2', 'pId', sinkId, sourceUrn, true, '', null); + const callback = function() {}; + + providerManager + .updateMirroring( + mockProvider1, localRoute, sourceUrn, undefined, callback) + .promise.catch(done); + }); + }); + + describe('Test PersistentData', function() { + it('ProviderManager is restored properly as PersistentData', function() { + providerManager.registerAllProviders([mockProvider1]); + providerManager.sinkQueries_.add(sourceUrn); + providerManager.routeQueries_.add(sourceUrn); + providerManager.routeIdToProvider_.set(routeId, mockProvider1); + providerManager.sinkAvailabilityMap_.set( + mockProvider1Name, mr.SinkAvailability.AVAILABLE); + + const expectedSinkQueries = new Set(providerManager.sinkQueries_); + const expectedRouteQueries = new Set(providerManager.routeQueries_); + + const data = providerManager.getData(); + expect(data.length).toEqual(1); + const tempData = data[0]; + expect(tempData.providerNames).toEqual([mockProvider1Name]); + expect(tempData.sinkQueries).toEqual([sourceUrn]); + expect(tempData.routeQueries).toEqual([sourceUrn]); + expect(tempData.routeIdToProviderName).toEqual([ + [routeId, mockProvider1Name] + ]); + expect(tempData.sinkAvailabilityMap).toEqual([ + [mockProvider1Name, mr.SinkAvailability.AVAILABLE] + ]); + + // Data is saved to localStorage. + mr.PersistentDataManager.onSuspend_(); + + // Make PersistentDataManager forget providerManager, so it can be + // registered again. + mr.PersistentDataManager.dataInstances_.clear(); + + providerManager.routeIdToProvider_.clear(); + providerManager.sinkQueries_.clear(); + providerManager.routeQueries_.clear(); + providerManager.sinkAvailabilityMap_.clear(); + + // Load data back to providerManager. + mockProvider1.getAvailableSinks.and.returnValue(mr.SinkList.EMPTY); + mr.PersistentDataManager.register(providerManager); + expect(providerManager.sinkQueries_).toEqual(expectedSinkQueries); + expect(providerManager.routeQueries_).toEqual(expectedRouteQueries); + expect(providerManager.routeIdToProvider_.get(routeId)) + .toEqual(mockProvider1); + expect(providerManager.sinkAvailabilityMap_.get(mockProvider1Name)) + .toEqual(mr.SinkAvailability.AVAILABLE); + }); + + it('ProviderManager calls mDNS callbacks if mDNS enabled', function() { + let callbackRuns = 0; + const callback = function() { + ++callbackRuns; + }; + providerManager.mdnsEnabled_ = true; + + // Data is saved to localStorage. + mr.PersistentDataManager.onSuspend_(); + + // Prevent immediate callback so we are sure it runs in loadSavedData + providerManager.mdnsEnabled_ = false; + providerManager.registerMdnsDiscoveryEnabledCallback(callback); + expect(callbackRuns).toBe(0); + + // Make PersistentDataManager forget providerManager, so it can be + // registered again. + mr.PersistentDataManager.dataInstances_.clear(); + + // Load data back to providerManager. + mockProvider1.getAvailableSinks.and.returnValue(mr.SinkList.EMPTY); + mr.PersistentDataManager.register(providerManager); + expect(callbackRuns).toBe(1); + }); + }); + + describe('Test onRouteRemoved', function() { + let sourceUrn; + let sinkId; + let localRoute; + + beforeEach(function() { + sourceUrn = 'urn:dial-multiscreen-org:dial:application:YouTube'; + sinkId = 'sink1'; + localRoute = new mr.Route('r2', 'pId', sinkId, sourceUrn, true, '', null); + providerManager.registerAllProviders([mockProvider1]); + spyOn(providerManager.mrRouteMessageSender_, 'onRouteRemoved') + .and.callThrough(); + sourceUrn = mr.MediaSourceUtils.DESKTOP_MIRROR_URN; + localRoute = new mr.Route('r2', 'pId', sinkId, sourceUrn, true, '', null); + mockProvider1.canRoute.and.returnValue(true); + const mirrorSettings = {}; + mockProvider1.getMirrorSettings.and.returnValue(mirrorSettings); + mockProvider1.getMirrorServiceName.and.returnValue( + mr.mirror.ServiceName.CAST_STREAMING); + mockProvider1.createRoute.and.callFake(() => { + providerManager.onRouteAdded(mockProvider1, localRoute); + return mr.CancellablePromise.resolve(localRoute); + }); + mockCastMirrorService.startMirroring.and.returnValue( + mr.CancellablePromise.resolve(localRoute)); + }); + + it('On a mirror session stopped', function(done) { + let count = 0; + const checkDone = () => { + if (++count == 2) { + expect(providerManager.routeIdToProvider_.has(localRoute.id)) + .toBe(false); + expect(providerManager.routeIdToMirrorServiceName_.has(localRoute.id)) + .toBe(false); + expect(mockCastMirrorService.stopCurrentMirroring).toHaveBeenCalled(); + expect(providerManager.mrRouteMessageSender_.onRouteRemoved) + .toHaveBeenCalledWith(localRoute.id); + done(); + } + }; + mockCastMirrorService.stopCurrentMirroring.and.callFake(checkDone); + providerManager.mrRouteMessageSender_.onRouteRemoved.and.callFake( + checkDone); + providerManager.createRoute(sourceUrn, sinkId, presentationId) + .then(route => { + providerManager.startMirroring(mockProvider1, route) + .promise.then(r => { + expect(route.id).toEqual(localRoute.id); + expect(providerManager.routeIdToProvider_.has(localRoute.id)) + .toBe(true); + expect(providerManager.routeIdToMirrorServiceName_.has( + localRoute.id)) + .toBe(true); + providerManager.onRouteRemoved(mockProvider1, localRoute); + }); + }); + }); + }); + + it('Test add/remove MediaSinksQuery', function() { + const sourceUrn = 'urn:x-org.chromium.media:source:desktop'; + const sink1 = new mr.Sink('s1', 'sink1'); + const sink2 = new mr.Sink('s2', 'sink2'); + const origins = ['https://www.google.com', 'https://youtube.com']; + providerManager.registerAllProviders([mockProvider1, mockProvider2]); + mockProvider1.getAvailableSinks.and.returnValue( + new mr.SinkList([sink1, sink2], origins)); + mockProvider2.getAvailableSinks.and.returnValue(mr.SinkList.EMPTY); + providerManager.startObservingMediaSinks(sourceUrn); + expect(mockProvider1.startObservingMediaSinks) + .toHaveBeenCalledWith(sourceUrn); + expect(mockProvider2.startObservingMediaSinks) + .toHaveBeenCalledWith(sourceUrn); + expect(mockMediaRouterService.onSinksReceived) + .toHaveBeenCalledWith(sourceUrn, [sink1, sink2], jasmine.any(Object)); + const originList = + mockMediaRouterService.onSinksReceived.calls.argsFor(0)[2]; + expect(originList.length).toBe(2); + expect(originList[0].scheme).toBe('https'); + expect(originList[0].host).toBe('www.google.com'); + expect(originList[1].scheme).toBe('https'); + expect(originList[1].host).toBe('youtube.com'); + // add again + providerManager.startObservingMediaSinks(sourceUrn); + expect(mockProvider1.startObservingMediaSinks.calls.count()).toBe(1); + expect(mockProvider2.startObservingMediaSinks.calls.count()).toBe(1); + expect(mockMediaRouterService.onSinksReceived.calls.count()).toBe(2); + // remove + providerManager.stopObservingMediaSinks(sourceUrn); + expect(mockProvider1.stopObservingMediaSinks) + .toHaveBeenCalledWith(sourceUrn); + expect(mockProvider2.stopObservingMediaSinks) + .toHaveBeenCalledWith(sourceUrn); + // add again + providerManager.startObservingMediaSinks(sourceUrn); + expect(mockProvider1.startObservingMediaSinks.calls.count()).toBe(2); + expect(mockProvider2.startObservingMediaSinks.calls.count()).toBe(2); + expect(mockMediaRouterService.onSinksReceived.calls.count()).toBe(3); + }); + + it('Test onSinkAvailabilityUpdated', function() { + providerManager.registerAllProviders([mockProvider1, mockProvider2]); + + providerManager.onSinkAvailabilityUpdated( + mockProvider1, mr.SinkAvailability.UNAVAILABLE); + expect(mockMediaRouterService.onSinkAvailabilityUpdated) + .not.toHaveBeenCalled(); + + providerManager.onSinkAvailabilityUpdated( + mockProvider1, mr.SinkAvailability.PER_SOURCE); + expect(mockMediaRouterService.onSinkAvailabilityUpdated) + .toHaveBeenCalledWith(mr.SinkAvailability.PER_SOURCE); + + providerManager.onSinkAvailabilityUpdated( + mockProvider2, mr.SinkAvailability.PER_SOURCE); + expect(mockMediaRouterService.onSinkAvailabilityUpdated.calls.count()) + .toBe(1); + + providerManager.onSinkAvailabilityUpdated( + mockProvider2, mr.SinkAvailability.AVAILABLE); + expect(mockMediaRouterService.onSinkAvailabilityUpdated) + .toHaveBeenCalledWith(mr.SinkAvailability.AVAILABLE); + + providerManager.onSinkAvailabilityUpdated( + mockProvider1, mr.SinkAvailability.UNAVAILABLE); + expect(mockMediaRouterService.onSinkAvailabilityUpdated.calls.count()) + .toBe(2); + + providerManager.onSinkAvailabilityUpdated( + mockProvider2, mr.SinkAvailability.UNAVAILABLE); + expect(mockMediaRouterService.onSinkAvailabilityUpdated) + .toHaveBeenCalledWith(mr.SinkAvailability.UNAVAILABLE); + }); + + it('Test mDNS callbacks wait until mDNS is enabled', function() { + providerManager.mdnsEnabled_ = true; + let callbackRuns = 0; + const callback = function() { + ++callbackRuns; + }; + providerManager.registerMdnsDiscoveryEnabledCallback(callback); + // Execute immediately when mDNS discovery is enabled. + expect(callbackRuns).toBe(1); + + callbackRuns = 0; + providerManager.mdnsEnabled_ = false; + providerManager.registerMdnsDiscoveryEnabledCallback(callback); + // Defer execution until mDNS discovery is enabled. + expect(callbackRuns).toBe(0); + providerManager.enableMdnsDiscovery(); + expect(callbackRuns).toBe(1); + }); + + it('Test mDNS callback registration disallows duplicates', function() { + providerManager.mdnsEnabled_ = true; + let callbackRuns = 0; + const callback = function() { + ++callbackRuns; + }; + + providerManager.mdnsEnabled_ = false; + providerManager.registerMdnsDiscoveryEnabledCallback(callback); + providerManager.registerMdnsDiscoveryEnabledCallback(callback); + // Defer execution until mDNS discovery is enabled and ensure only called + // once. + expect(callbackRuns).toBe(0); + providerManager.enableMdnsDiscovery(); + expect(callbackRuns).toBe(1); + }); + + describe('searchSinks', function() { + const getSearchCriteria = function(input) { + return {'input': input, 'domain': 'google.com'}; + }; + let pseudoId; + + beforeEach(function() { + sourceUrn = 'urn:x-org.chromium.media:source:desktop'; + pseudoId = 'pseudo:' + mockProvider1Name; + providerManager.registerAllProviders([mockProvider1, mockProvider2]); + }); + + it('returns the sink id if the provider returns a sink', done => { + const sinkId = 'sinkId1'; + const sinkName = 'sink name'; + const foundSink = new mr.Sink(sinkId, sinkName); + mockProvider1.searchSinks.and.returnValue(Promise.resolve(foundSink)); + + providerManager + .searchSinks(pseudoId, sourceUrn, getSearchCriteria(sinkName)) + .then((resolvedSinkId) => { + expect(resolvedSinkId).toBe(sinkId); + done(); + }); + }); + + it('returns nothing if the provider returns nothing', done => { + mockProvider1.searchSinks.and.returnValue( + Promise.resolve(new mr.Sink('', ''))); + providerManager + .searchSinks(pseudoId, sourceUrn, getSearchCriteria('sink name')) + .then((resolvedSinkId) => expect(resolvedSinkId).toBe('')) + .then(done, done.fail); + }); + + it('returns nothing when no provider owns the pseudo sink', done => { + pending( + 'This may never have worked, because the test ' + + 'wasn\'t checking what it was supposed to check.'); + pseudoId = 'pseudo:bad'; + providerManager + .searchSinks(pseudoId, sourceUrn, getSearchCriteria('sink name')) + .then((resolvedSinkId) => expect(resolvedSinkId).toBe('')) + .then(done, done.fail); + }); + }); + + describe('updateMediaSinks', function() { + const sink1 = new mr.Sink('s1', 'sink1'); + const sink2 = new mr.Sink('s2', 'sink2'); + + beforeEach(function() { + sourceUrn = 'urn:x-org.chromium.media:source:desktop'; + providerManager.registerAllProviders([mockProvider1, mockProvider2]); + mockProvider1.getAvailableSinks.and.returnValue( + new mr.SinkList([sink1, sink2], ['https://www.google.com'])); + mockProvider2.getAvailableSinks.and.returnValue(mr.SinkList.EMPTY); + }); + + it('queries providers with desktop source', function() { + providerManager.updateMediaSinks(sourceUrn); + expect(mockProvider1.getAvailableSinks).toHaveBeenCalledWith(sourceUrn); + expect(mockProvider2.getAvailableSinks).toHaveBeenCalledWith(sourceUrn); + expect(mockMediaRouterService.onSinksReceived) + .toHaveBeenCalledWith( + sourceUrn, [sink1, sink2], [jasmine.any(Object)]); + const originList = + mockMediaRouterService.onSinksReceived.calls.argsFor(0)[2]; + expect(originList.length).toBe(1); + expect(originList[0].scheme).toBe('https'); + expect(originList[0].host).toBe('www.google.com'); + }); + + it('returns immediately if query already exists', function() { + providerManager.sinkQueries_.add(sourceUrn); + // updateMediaSinks should return and not touch providers + providerManager.updateMediaSinks(sourceUrn); + expect(mockProvider1.getAvailableSinks).not.toHaveBeenCalled(); + expect(mockProvider2.getAvailableSinks).not.toHaveBeenCalled(); + expect(mockMediaRouterService.onSinksReceived).not.toHaveBeenCalled(); + }); + }); + + describe('calls terminateRoute', function() { + const routeIdToTerminate = 'deadbeef'; + + beforeEach(function() { + providerManager.registerAllProviders([mockProvider1]); + providerManager.routeIdToProvider_.set(routeIdToTerminate, mockProvider1); + }); + + it('with correct routeId', function(done) { + mockProvider1.terminateRoute.and.callFake(routeId => { + expect(routeId).toBe(routeIdToTerminate); + done(); + }); + providerManager.terminateRoute(routeIdToTerminate); + }); + + it('and returns a rejected promise when it fails', function(done) { + mockProvider1.terminateRoute.and.callFake(routeId => { + expect(routeId).toBe(routeIdToTerminate); + return Promise.reject(new Error('Some error')); + }); + providerManager.terminateRoute(routeIdToTerminate).then(done.fail, done); + }); + + it('and terminates a mirroring route', function(done) { + providerManager.routeIdToMirrorServiceName_.set( + routeIdToTerminate, mr.mirror.ServiceName.CAST_STREAMING); + providerManager.terminateRoute(routeIdToTerminate).then(() => { + expect(mockProvider1.terminateRoute) + .toHaveBeenCalledWith(routeIdToTerminate); + expect(mockCastMirrorService.stopCurrentMirroring).toHaveBeenCalled(); + expect( + providerManager.routeIdToMirrorServiceName_.has(routeIdToTerminate)) + .toBe(false); + done(); + }); + }); + }); + + describe('createMediaRouteController tests', () => { + const routeId = 'deadbeef'; + let controller; + let observer; + + beforeEach(() => { + controller = mr.UnitTestUtils.createMojoRequestSpyObj(); + observer = mr.UnitTestUtils.createMojoMediaStatusObserverSpyObj(); + providerManager.registerAllProviders([mockProvider1]); + providerManager.routeIdToProvider_.set(routeId, mockProvider1); + }); + + it('succeeds', done => { + mockProvider1.createMediaRouteController.and.callFake( + () => Promise.resolve()); + providerManager.createMediaRouteController(routeId, controller, observer) + .then(() => { + expect(mockProvider1.createMediaRouteController).toHaveBeenCalled(); + expect(controller.close).not.toHaveBeenCalled(); + expect(observer.ptr.reset).not.toHaveBeenCalled(); + done(); + }, fail); + }); + + it('fails if route does not exist', done => { + providerManager + .createMediaRouteController( + 'nonexistentRouteId', controller, observer) + .then(fail, () => { + expect(controller.close).toHaveBeenCalled(); + expect(observer.ptr.reset).toHaveBeenCalled(); + done(); + }); + }); + + it('fails if provider fails', done => { + mockProvider1.createMediaRouteController.and.callFake( + () => Promise.reject('Foo')); + providerManager.createMediaRouteController(routeId, controller, observer) + .then(fail, () => { + expect(mockProvider1.createMediaRouteController).toHaveBeenCalled(); + expect(controller.close).toHaveBeenCalled(); + expect(observer.ptr.reset).toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/route_id.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_id.js new file mode 100644 index 00000000000..a5d9f37a9d0 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_id.js @@ -0,0 +1,114 @@ +// 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. + +/** + * @fileoverview A routeId includes presentation ID, provider name, + * sink ID and media source. See mr.RouteId.getRouteId method for its + * format (note the different formatting of routeId for 2UA cases). + */ + +goog.provide('mr.RouteId'); + +goog.require('mr.MediaSourceUtils'); + +/** + * @final + */ +mr.RouteId = class { + constructor() { + /** @private {string} */ + this.routeId_; + + /** @private {string} */ + this.presentationId_; + + /** @private {string} */ + this.providerName_; + + /** @private {string} */ + this.sinkId_; + + /** @private {string} */ + this.source_; + } + + /** + * @param {string} routeId + * @return {?mr.RouteId} A route ID object if the input string is valid; + * null otherwise. + */ + static create(routeId) { + if (!routeId.startsWith(mr.RouteId.PREFIX_)) { + return null; + } + const suffix = routeId.substring(mr.RouteId.PREFIX_.length); + if (!suffix) { + return null; + } + const match = suffix.match(/([^/]*)\/([^-/]*)-([^/]*)\/(.*)/); + if (!match) { + return null; + } + const obj = new mr.RouteId(); + obj.routeId_ = routeId; + [, obj.presentationId_, obj.providerName_, obj.sinkId_, obj.source_] = + match; + return obj; + } + + /** + * @param {string} presentationId + * @param {string} providerName + * @param {string} sinkId + * @param {?string} source + * @return {string} + */ + static getRouteId(presentationId, providerName, sinkId, source) { + // In a 2UA mode, the routeId should be the presentationId. + if (source && mr.MediaSourceUtils.isPresentationSource(source)) { + return presentationId; + } + return mr.RouteId.PREFIX_ + presentationId + '/' + providerName + '-' + + sinkId + '/' + source; + } + + /** + * @return {string} + */ + getRouteId() { + return this.routeId_; + } + + /** + * @return {string} + */ + getPresentationId() { + return this.presentationId_; + } + + /** + * @return {string} + */ + getProviderName() { + return this.providerName_; + } + + /** + * @return {string} + */ + getSinkId() { + return this.sinkId_; + } + + /** + * @return {string} + */ + getSource() { + return this.source_; + } +}; + + +/** @private @const {string} */ +mr.RouteId.PREFIX_ = 'urn:x-org.chromium:media:route:'; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/route_id_test.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_id_test.js new file mode 100644 index 00000000000..daff81e818f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_id_test.js @@ -0,0 +1,41 @@ +// 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. + +goog.require('mr.RouteId'); + +describe('Tests RouteId', function() { + it('Test getRouteId', function() { + expect(mr.RouteId.getRouteId('123', 'cast', 'sink1', 'some-source')) + .toEqual(mr.RouteId.PREFIX_ + '123/cast-sink1/some-source'); + }); + + it('Test getRouteId in 2UA mode', function() { + expect(mr.RouteId.getRouteId('123', 'cast', 'sink1', 'http://mysite.com')) + .toEqual('123'); + }); + + it('Test getRouteId with valid input', function() { + let routeId = mr.RouteId.PREFIX_ + '123/cast-sink1/some-source'; + let obj = mr.RouteId.create(routeId); + expect(obj.getRouteId()).toEqual(routeId); + expect(obj.getPresentationId()).toEqual('123'); + expect(obj.getProviderName()).toEqual('cast'); + expect(obj.getSinkId()).toEqual('sink1'); + expect(obj.getSource()).toEqual('some-source'); + + routeId = mr.RouteId.PREFIX_ + '/cast-sink1/some-source'; + obj = mr.RouteId.create(routeId); + expect(obj.getRouteId()).toEqual(routeId); + expect(obj.getPresentationId()).toEqual(''); + expect(obj.getProviderName()).toEqual('cast'); + expect(obj.getSinkId()).toEqual('sink1'); + expect(obj.getSource()).toEqual('some-source'); + }); + + it('Test getRouteId with invalid input 1', function() { + expect(mr.RouteId.create('123')).toBe(null); + expect(mr.RouteId.create(mr.RouteId.PREFIX_ + '123/cast-sink1')).toBe(null); + expect(mr.RouteId.create(mr.RouteId.PREFIX_ + '123/cast-')).toBe(null); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/route_message_port.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_message_port.js new file mode 100644 index 00000000000..d6cae814fe9 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_message_port.js @@ -0,0 +1,74 @@ +// 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. + +/** + * @fileoverview API for posting messages to a route and getting messages from + * the route. + */ + +goog.provide('mr.MessagePort'); +goog.provide('mr.MessagePortService'); + + + +/** + * @record + */ +mr.MessagePort = class { + /** + * Called to post a message to the sink via a media route. + * @param {!Object|string} message The message to post. Object will be + * serialized to a JSON string. + * @param {Object=} opt_extraInfo Extra info about how to send a message. + * @return {!Promise} Fulfilled when the message is posted, or rejected + * if there an error. + */ + sendMessage(message, opt_extraInfo) {} + + /** + * Releases any resources used by this object. + */ + dispose() {} +}; + + +/** + * The message handler to invoke when messages associated + * with the route arrives. + * @type {function((!Object|string))} + */ +mr.MessagePort.prototype.onMessage; + + + +/** + * @record + */ +mr.MessagePortService = class { + /** + * Returns the MessagePort for internal communication with the provider. + * @param {string} routeId + * @return {!mr.MessagePort} + */ + getInternalMessenger(routeId) {} + /** + * @return {!mr.MessagePortService} + */ + static getService() { + return mr.MessagePortService.service_; + } + + /** + * @param {!mr.MessagePortService} service + */ + static setService(service) { + if (!mr.MessagePortService.service_) { + mr.MessagePortService.service_ = service; + } + } +}; + + +/** @private {!mr.MessagePortService} */ +mr.MessagePortService.service_; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/route_message_port_impl.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_message_port_impl.js new file mode 100644 index 00000000000..78efd2d018c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/route_message_port_impl.js @@ -0,0 +1,107 @@ +// 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. + + + +goog.provide('mr.MessagePortServiceImpl'); + +goog.require('mr.EventTarget'); +goog.require('mr.MessagePort'); +goog.require('mr.MessagePortService'); +goog.require('mr.ProviderEventType'); + + +/** + * @template T + * @implements {mr.MessagePort} + * @private + */ +mr.MessagePortImpl_ = class { + /** + * @param {string} routeId + * @param {function(string, (!Object|string), Object=): !Promise} + * sendMessage + * @param {!mr.EventTarget} messageEventTarget + */ + constructor(routeId, sendMessage, messageEventTarget) { + /** @private {string} */ + this.routeId_ = routeId; + + /** @private {function(string, (!Object|string), Object=): !Promise} */ + this.sendMessage_ = sendMessage; + + /** @private {!mr.EventTarget} */ + this.messageEventTarget_ = messageEventTarget; + + /** @type {function((!Object|string))} */ + this.onMessage = () => {}; + + messageEventTarget.listen( + mr.ProviderEventType.INTERNAL_MESSAGE, this.onRouteMessage_, this); + } + + /** + * @override + */ + sendMessage(message, opt_extraInfo) { + return this.sendMessage_(this.routeId_, message, opt_extraInfo); + } + + /** + * Called when a message is received. + * + * @param {mr.InternalMessageEvent.<!Object|string>} event + * @private + */ + onRouteMessage_(event) { + if (event.routeId != this.routeId_) { + return; + } + this.onMessage(event.message); + } + + /** + * @override + */ + dispose() { + + this.onMessage = () => {}; + this.messageEventTarget_.unlisten( + mr.ProviderEventType.INTERNAL_MESSAGE, this.onRouteMessage_, this); + } +}; + + +/** @private @const {!mr.MessagePort} */ +mr.MessagePortImpl_.NULL_INSTANCE_ = + new mr.MessagePortImpl_('', () => Promise.resolve(), new mr.EventTarget()); + + +/** + * @implements {mr.MessagePortService} + */ +mr.MessagePortServiceImpl = class { + /** + * @param {!mr.ProviderManagerCallbacks} providerManagerCallbacks + */ + constructor(providerManagerCallbacks) { + /** + * @private {!mr.ProviderManagerCallbacks} + */ + this.providerManagerCallbacks_ = providerManagerCallbacks; + } + + /** + * @override + */ + getInternalMessenger(routeId) { + const provider = + this.providerManagerCallbacks_.getProviderFromRouteId(routeId); + return !provider ? + mr.MessagePortImpl_.NULL_INSTANCE_ : + new mr.MessagePortImpl_( + routeId, provider.sendRouteMessage.bind(provider), + this.providerManagerCallbacks_.getRouteMessageEventTarget()); + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/manager/sink_availability.js b/chromium/chrome/browser/resources/media_router/extension/src/manager/sink_availability.js new file mode 100644 index 00000000000..5c3c1ce0862 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/manager/sink_availability.js @@ -0,0 +1,21 @@ +// 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. + +/** + * @fileoverview Enum for per-media-route-provider sink availability. + */ + +goog.provide('mr.SinkAvailability'); + + +/** + * Per-provider sink availability. + * Keep in sync with MediaRouter.SinkAvailability in media_router.mojom. + * @enum {number} + */ +mr.SinkAvailability = { + UNAVAILABLE: 0, + PER_SOURCE: 1, + AVAILABLE: 2 +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_activity.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_activity.js new file mode 100644 index 00000000000..df658d67a66 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_activity.js @@ -0,0 +1,150 @@ +// Copyright 2018 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. + +/** + * @fileoverview Mirroring activities for display in the UI. + */ + +goog.module('mr.mirror.Activity'); +goog.module.declareLegacyNamespace(); + +const Assertions = goog.require('mr.Assertions'); +const MediaSourceUtils = goog.require('mr.MediaSourceUtils'); + + +/** + * Possible mirroring activities. + * @enum {string} + */ +const Type = { + MIRROR_TAB: 'mirror_tab', + MIRROR_DESKTOP: 'mirror_desktop', + MIRROR_FILE: 'mirror_file', + MEDIA_REMOTING: 'media_remoting', + PRESENTATION: 'presentation' +}; + + +/** + * Describes a mirroring activity for display in the UI. + */ +const Activity = class { + /** + * Constructs a new Activity describing a mirroring route. origin is + * required, unless the activityType is MIRROR_DESKTOP. Both contentTitle and + * origin may change over the course of a mirroring activity from tab + * navigation. + * + * @param {!Type} type + * @param {boolean} incognito + * @param {?string=} origin The top level origin of the tab being cast. + */ + constructor(type, incognito, origin = null) { + /** @private {!Type} */ + this.type_ = type; + + /** @private {boolean} */ + this.incognito_ = incognito; + + /** @private {?string} */ + this.origin_ = origin; + + /** @private {?string} */ + this.contentTitle_ = null; + } + + /** + * @param {!mr.Route} route A mirroring route + * @return {!Activity} Intital activity object for route + */ + static createFromRoute(route) { + let type; + let origin = null; + const source = /** @type {string} */ (Assertions.assert(route.mediaSource)); + if (MediaSourceUtils.isPresentationSource(source)) { + type = Type.PRESENTATION; + origin = new URL(source).origin; + } else if (MediaSourceUtils.isTabMirrorSource(source)) { + // Tab mirroring routes may switch to MEDIA_REMOTING or MIRROR_FILE after + // they have begun. + type = Type.MIRROR_TAB; + } else if (MediaSourceUtils.isDesktopMirrorSource(source)) { + type = Type.MIRROR_DESKTOP; + } + Assertions.assert(type, `Unexpected mediaSource ${source}`); + return new Activity( + /** @type {Type} */ (type), route.offTheRecord, origin); + } + + /** @param {Type} type Sets the activity type. */ + setType(type) { + this.type_ = type; + } + + /** @param {?string} origin Sets the current origin of the activity. */ + setOrigin(origin) { + this.origin_ = origin; + } + + /** @param {?string} contentTitle Sets the content title of the activity. */ + setContentTitle(contentTitle) { + this.contentTitle_ = contentTitle; + } + + /** @param {boolean} incognito Sets the incognito status of the activity. */ + setIncognito(incognito) { + this.incognito_ = incognito; + } + + /** @return {string} The string to use for the route's description. */ + getRouteDescription() { + switch (this.type_) { + case Type.MIRROR_TAB: + return this.origin_ ? `Casting tab (${this.origin_})` : 'Casting tab'; + case Type.MIRROR_DESKTOP: + return 'Casting desktop'; + case Type.MIRROR_FILE: + return 'Casting local content'; + case Type.MEDIA_REMOTING: + return this.origin_ ? `Casting media (${this.origin_})` : + `Casting media`; + case Type.PRESENTATION: + return `Casting ${this.origin_ || 'site'}`; + default: + Assertions.assert(false, 'Unexpected type ' + this.type_); + return ''; + } + } + + /** @return {string} The string to use for the route's media status. */ + getRouteMediaStatus() { + if (this.type_ == Type.MIRROR_DESKTOP) { + return ''; + } + return this.contentTitle_ || ''; + } + + /** @return {string} The string to broadcast to other Cast senders. */ + getCastRemoteTitle() { + if (this.incognito_) return 'Casting active'; + switch (this.type_) { + case Type.MIRROR_TAB: + return 'Casting tab'; + case Type.MIRROR_DESKTOP: + return 'Casting desktop'; + case Type.MIRROR_FILE: + return 'Casting local content'; + case Type.MEDIA_REMOTING: + return 'Casting media'; + case Type.PRESENTATION: + return 'Casting site'; + default: + Assertions.assert(false, 'Unexpected type ' + this.type_); + return ''; + } + } +}; + +exports = Activity; +exports.Type = Type; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_activity_test.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_activity_test.js new file mode 100644 index 00000000000..e431bd7dd14 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_activity_test.js @@ -0,0 +1,93 @@ +// Copyright 2018 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. + +goog.setTestOnly(); +goog.require('mr.Route'); +goog.require('mr.mirror.Activity'); + +describe('Tests mr.mirror.Activity', () => { + let tabMirrorRoute; + let desktopMirrorRoute; + let presentationRoute; + + beforeEach(() => { + tabMirrorRoute = new mr.Route( + 'routeId', 'presentationId', 'sinkId', + 'urn:x-org.chromium.media:source:tab:47', true, null); + desktopMirrorRoute = new mr.Route( + 'routeId2', 'presentationId2', 'sinkId2', + 'urn:x-org.chromium.media:source:desktop', true, null); + presentationRoute = new mr.Route( + 'routeId3', 'presentationId3', 'sinkId3', 'https://www.example.com', + true, null); + }); + + it('creates activity from a tab mirroring route', () => { + let activity = mr.mirror.Activity.createFromRoute(tabMirrorRoute); + expect(activity.getRouteDescription()).toBe('Casting tab'); + expect(activity.getRouteMediaStatus()).toBe(''); + expect(activity.getCastRemoteTitle()).toBe('Casting tab'); + }); + + it('creates activity from a desktop mirroring route', () => { + let activity = mr.mirror.Activity.createFromRoute(desktopMirrorRoute); + expect(activity.getRouteDescription()).toBe('Casting desktop'); + expect(activity.getRouteMediaStatus()).toBe(''); + expect(activity.getCastRemoteTitle()).toBe('Casting desktop'); + }); + + it('creates activity from a presentation route', () => { + let activity = mr.mirror.Activity.createFromRoute(presentationRoute); + expect(activity.getRouteDescription()) + .toBe('Casting https://www.example.com'); + expect(activity.getRouteMediaStatus()).toBe(''); + expect(activity.getCastRemoteTitle()).toBe('Casting site'); + }); + + it('uses the tab origin and page title for tab mirroring', () => { + let activity = mr.mirror.Activity.createFromRoute(tabMirrorRoute); + activity.setOrigin('news.google.com'); + activity.setContentTitle('Google News'); + expect(activity.getRouteDescription()) + .toBe('Casting tab (news.google.com)'); + expect(activity.getRouteMediaStatus()).toBe('Google News'); + expect(activity.getCastRemoteTitle()).toBe('Casting tab'); + }); + + it('uses the tab origin and page title for presentation', () => { + let activity = mr.mirror.Activity.createFromRoute(presentationRoute); + activity.setOrigin('www.example.com'); + activity.setContentTitle('Some Presentation'); + expect(activity.getRouteDescription()).toBe('Casting www.example.com'); + expect(activity.getRouteMediaStatus()).toBe('Some Presentation'); + expect(activity.getCastRemoteTitle()).toBe('Casting site'); + }); + + it('updates the remote title for incognito', () => { + let activity = mr.mirror.Activity.createFromRoute(tabMirrorRoute); + activity.setIncognito(true); + expect(activity.getCastRemoteTitle()).toBe('Casting active'); + }); + + it('updates the activity for remoting', () => { + let activity = mr.mirror.Activity.createFromRoute(tabMirrorRoute); + activity.setOrigin('www.vimeo.com'); + activity.setContentTitle('Vimeo'); + activity.setType(mr.mirror.Activity.Type.MEDIA_REMOTING); + expect(activity.getRouteDescription()) + .toBe('Casting media (www.vimeo.com)'); + expect(activity.getRouteMediaStatus()).toBe('Vimeo'); + expect(activity.getCastRemoteTitle()).toBe('Casting media'); + }); + + it('updates the activity for mirroring local media', () => { + let activity = mr.mirror.Activity.createFromRoute(tabMirrorRoute); + activity.setOrigin(''); + activity.setContentTitle('some_file.mp4'); + activity.setType(mr.mirror.Activity.Type.MIRROR_FILE); + expect(activity.getRouteDescription()).toBe('Casting local content'); + expect(activity.getRouteMediaStatus()).toBe('some_file.mp4'); + expect(activity.getCastRemoteTitle()).toBe('Casting local content'); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_analytics.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_analytics.js new file mode 100644 index 00000000000..20ea156371e --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_analytics.js @@ -0,0 +1,74 @@ +// 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. + +/** + * @fileoverview Defines UMA analytics specific to Mirroring. + */ + +goog.provide('mr.MirrorAnalytics'); +goog.provide('mr.mirror.Error'); + +goog.require('mr.Analytics'); + + +/** + * Contains all common analytics logic for Mirroring. + * @const {*} + */ +mr.MirrorAnalytics = {}; + + +/** + * Histogram name for mirroring start failure. + * @private @const {string} + */ +mr.MirrorAnalytics.CAPTURING_FAILURE_HISTOGRAM_ = + 'MediaRouter.Mirror.Capturing.Failure'; + + +/** + * Possible values for the start failure analytics. + * @enum {number} + */ +mr.MirrorAnalytics.CapturingFailure = { + CAPTURE_TAB_FAIL_EMPTY_STREAM: 0, + CAPTURE_DESKTOP_FAIL_ERROR_TIMEOUT: 1, + CAPTURE_TAB_TIMEOUT: 2, + CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL: 3, + ANSWER_NOT_RECEIVED: 4, + CAPTURE_TAB_FAIL_ERROR_TIMEOUT: 5, + ICE_CONNECTION_CLOSED: 6, + TAB_FAIL: 7, + DESKTOP_FAIL: 8, + UNKNOWN: 9, +}; + + +/** + * Records a mirroring start failure. + * @param {mr.MirrorAnalytics.CapturingFailure} reason The type of failure. + * @param {string} name The name of the histogram. + */ +mr.MirrorAnalytics.recordCapturingFailureWithName = function(reason, name) { + mr.Analytics.recordEnum(name, reason, mr.MirrorAnalytics.CapturingFailure); +}; + + +/** + * Error thrown when initiating a mirroring session. + */ +mr.mirror.Error = class extends Error { + /** + * @param {string} message The error message. + * @param {mr.MirrorAnalytics.CapturingFailure=} reason The failure reason. + */ + constructor(message, reason = undefined) { + super(message); + /** {!mr.MirrorAnalytics.CapturingFailure} The failure reason. */ + this.reason = + (reason >= 0 && reason <= mr.MirrorAnalytics.CapturingFailure.UNKNOWN) ? + reason : + mr.MirrorAnalytics.CapturingFailure.UNKNOWN; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_analytics_test.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_analytics_test.js new file mode 100644 index 00000000000..5bc5596eb43 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_analytics_test.js @@ -0,0 +1,111 @@ +// 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. + +goog.setTestOnly(); +goog.require('mr.MirrorAnalytics'); + +describe('Tests Analytics', function() { + const metricName = 'MediaRouter.Fake.Start.Failure'; + + beforeEach(function() { + chrome.metricsPrivate = { + recordTime: jasmine.createSpy('recordTime'), + recordMediumTime: jasmine.createSpy('recordMediumTime'), + recordLongTime: jasmine.createSpy('recordLongTime'), + recordUserAction: jasmine.createSpy('recordUserAction'), + recordValue: jasmine.createSpy('recordValue'), + }; + }); + + describe('Test Mirror Analytics', function() { + describe('Test recordCapturingFailure', function() { + const testConfig = { + 'metricName': metricName, + 'type': 'histogram-linear', + 'min': 1, + 'max': 10, + 'buckets': 11 + }; + it('Should record an empty stream error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.CAPTURE_TAB_FAIL_EMPTY_STREAM, + metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 0); + }); + it('Should record a desktop timeout error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_TIMEOUT, + metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 1); + }); + it('Should record a tab timeout error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.CAPTURE_TAB_TIMEOUT, + metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 2); + }); + it('Should record an user cancel error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL, + metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 3); + }); + it('Should record an answer not received error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.ANSWER_NOT_RECEIVED, + metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 4); + }); + it('Should record a tab fail error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.CAPTURE_TAB_FAIL_ERROR_TIMEOUT, + metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 5); + }); + it('Should record an ice connection closed error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.ICE_CONNECTION_CLOSED, + metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 6); + }); + it('Should record a tab failure', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.TAB_FAIL, metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 7); + }); + it('Should record a desktop failure', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.DESKTOP_FAIL, metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 8); + }); + it('Should record an unknown error', function() { + mr.MirrorAnalytics.recordCapturingFailureWithName( + mr.MirrorAnalytics.CapturingFailure.UNKNOWN, metricName); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 9); + }); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_config.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_config.js new file mode 100644 index 00000000000..fe079b2053b --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_config.js @@ -0,0 +1,31 @@ +// 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. + +/** + * @fileoverview Mirroring configs. + */ + +goog.provide('mr.mirror.Config'); +goog.require('mr.PlatformUtils'); + + +/** + * True if TDLS is supported by the browser and platform. + * @const {boolean} + */ +mr.mirror.Config.isTDLSSupportedByPlatform = Boolean( + typeof chrome != 'undefined' && chrome.networkingPrivate && + chrome.networkingPrivate.setWifiTDLSEnabledState && + mr.PlatformUtils.getCurrentOS() == mr.PlatformUtils.OS.CHROMEOS); + + +/** + * True if desktop audio capture is available. + * @const {boolean} + */ +mr.mirror.Config.isDesktopAudioCaptureAvailable = + // Audio capture is supported on ChromeOS with Chrome version + // 30.0.1584.0 and up, and Chrome 31 on Windows. + [mr.PlatformUtils.OS.CHROMEOS, mr.PlatformUtils.OS.WINDOWS].includes( + mr.PlatformUtils.getCurrentOS()); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service.js new file mode 100644 index 00000000000..c1bc326c9cf --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service.js @@ -0,0 +1,522 @@ +// 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. + +/** + * @fileoverview Service supporting tab and screen mirroring. + * + * This object is a singleton that controls all capture MediaStreams and all + * instances of MirrorSession. + * + + */ + +goog.provide('mr.mirror.Service'); + +goog.require('mr.Assertions'); +goog.require('mr.CancellablePromise'); +goog.require('mr.EventAnalytics'); +goog.require('mr.Issue'); +goog.require('mr.IssueSeverity'); +goog.require('mr.Logger'); +goog.require('mr.MediaSourceUtils'); +goog.require('mr.MirrorAnalytics'); +goog.require('mr.Module'); +goog.require('mr.mirror.CaptureParameters'); +goog.require('mr.mirror.CaptureSurfaceType'); +goog.require('mr.mirror.Error'); +goog.require('mr.mirror.Messages'); +goog.require('mr.mirror.MirrorMediaStream'); + +mr.mirror.Service = class extends mr.Module { + /** + * @param {!mr.mirror.ServiceName} serviceName + * @param {mr.ProviderManagerMirrorServiceCallbacks=} mirrorServiceCallbacks + */ + constructor(serviceName, mirrorServiceCallbacks) { + super(); + + /** @private @const {!mr.mirror.ServiceName} */ + this.serviceName_ = serviceName; + + /** @private {?mr.ProviderManagerMirrorServiceCallbacks} */ + this.mirrorServiceCallbacks_ = mirrorServiceCallbacks || null; + + /** @protected {?mr.mirror.Session} */ + this.currentSession = null; + + /** @private {?mr.mirror.MirrorMediaStream} */ + this.currentMediaStream_ = null; + + /** @protected @const */ + this.logger = mr.Logger.getInstance('mr.mirror.Service.' + serviceName); + + /** @private @const */ + this.onTabUpdated_ = this.handleTabUpdate_.bind(this); + + /** @private {boolean} */ + this.initialized_ = false; + } + + /** + * Initializes the service. Sets callbacks to provider manager. No-ops if + * already initialized. + * @param {!mr.ProviderManagerMirrorServiceCallbacks} mirrorServiceCallbacks + * Callbacks to provider manager. + */ + initialize(mirrorServiceCallbacks) { + if (this.initialized_) { + return; + } + this.mirrorServiceCallbacks_ = mirrorServiceCallbacks; + this.initialized_ = true; + this.doInitialize(); + } + + /** + * Called during initialization to perform service-specific initialization. + * @protected + */ + doInitialize() {} + + /** + * @return {mr.mirror.ServiceName} + */ + getName() { + return this.serviceName_; + } + + /** + * @param {!mr.Route} route + * @param {string} sourceUrn + * @param {!mr.mirror.Settings} mirrorSettings + * @param {string=} opt_presentationId + * @param {(function(!mr.Route): !mr.CancellablePromise)=} + * opt_streamStartedCallback Callback to invoke after stream capture + * succeeded and before the mirror session is created. The callback + * may update the route. + * @return {!mr.CancellablePromise<!mr.Route>} A promise fulfilled + * when mirroring has started successfully. + */ + startMirroring( + route, sourceUrn, mirrorSettings, opt_presentationId, + opt_streamStartedCallback) { + this.logger.info('Start mirroring on route ' + route.id); + if (!this.initialized_) { + return mr.CancellablePromise.reject(Error('Not initialized')); + } + const promise = new Promise((resolve, reject) => { + this.stopCurrentMirroring() + .then(() => { + const captureParams = mr.mirror.Service.createCaptureParameters_( + sourceUrn, mirrorSettings, opt_presentationId); + return new mr.mirror.MirrorMediaStream(captureParams).start(); + }) + .then(stream => { + if (this.currentMediaStream_) { + stream.stop(); + throw new mr.mirror.Error('Cannot start multiple streams'); + } + this.currentMediaStream_ = stream; + this.currentMediaStream_.setOnStreamEnded(this.cleanup_.bind(this)); + if (opt_streamStartedCallback) { + // Yuck. Converting a CancellablePromise to a plain Promise + // prevents cancellation from propagating correctly. + return opt_streamStartedCallback(route).promise; + } + return route; + }) + .then(updatedRoute => { + if (this.currentSession) { + throw new mr.mirror.Error('Cannot start multiple sessions'); + } + if (!this.currentMediaStream_) { + throw new mr.mirror.Error( + 'Media stream ended before session could start.'); + } + this.currentSession = + this.createMirrorSession(mirrorSettings, updatedRoute); + updatedRoute.mirrorInitData.activity = + this.currentSession.getActivity(); + this.currentSession.setOnActivityUpdate( + this.mirrorServiceCallbacks_.handleMirrorActivityUpdate.bind( + this.mirrorServiceCallbacks_)); + return this.currentSession.start(/** @type {!MediaStream} */ ( + this.currentMediaStream_.getMediaStream())); + }) + .then(() => { + if (mr.MediaSourceUtils.isTabMirrorSource(sourceUrn) && + !chrome.tabs.onUpdated.hasListener(this.onTabUpdated_)) { + chrome.tabs.onUpdated.addListener(this.onTabUpdated_); + } + return this.postProcessMirroring_(route, sourceUrn, mirrorSettings); + }) + .then(() => { + resolve(route); + }) + .catch(err => { + + this.onStartError_(/** @type {!Error} */ (err)); + return this.cleanup_().then(() => { + reject(err); + }); + }); + }); + return mr.CancellablePromise.forPromise(promise); + } + + /** + * @param {!mr.Route} route + * @param {string} sourceUrn + * @param {!mr.mirror.Settings} mirrorSettings + * @param {string=} opt_presentationId + * @param {number=} opt_tabId + * @param {(function(!mr.Route): !mr.CancellablePromise<!mr.Route>)=} + * opt_streamStartedCallback Callback to invoke after stream capture + * succeeded and before the mirror session is created. The callback may + * update the route. + * @return {!mr.CancellablePromise<!mr.Route>} A promise fulfilled + * when mirroring has started successfully. + */ + updateMirroring( + route, sourceUrn, mirrorSettings, opt_presentationId, opt_tabId, + opt_streamStartedCallback) { + this.logger.info('Update mirroring on route ' + route.id); + if (!this.initialized_) { + return mr.CancellablePromise.reject(Error('Not initialized')); + } + return mr.CancellablePromise.forPromise(this.doUpdateMirroring_( + route, sourceUrn, mirrorSettings, opt_presentationId, + opt_streamStartedCallback)); + } + + /** + * A helper method for updateMirroring. + * @param {!mr.Route} route + * @param {string} sourceUrn + * @param {!mr.mirror.Settings} mirrorSettings + * @param {string=} opt_presentationId + * @param {(function(!mr.Route): !mr.CancellablePromise<!mr.Route>)=} + * opt_streamStartedCallback Callback to invoke after stream capture + * succeeded and before the mirror session is created. The callback may + * update the route. + * @return {!Promise<!mr.Route>} A promise fulfilled + * when mirroring has started successfully. + * @private + */ + doUpdateMirroring_( + route, sourceUrn, mirrorSettings, opt_presentationId, + opt_streamStartedCallback) { + if (!this.currentSession) { + return Promise.reject(new mr.mirror.Error( + 'No session to update streams on', + mr.MirrorAnalytics.CapturingFailure.TAB_FAIL)); + } + if (!this.currentSession.supportsUpdateStream()) { + return Promise.reject(new mr.mirror.Error( + 'Session does not support updating stream', + mr.MirrorAnalytics.CapturingFailure.TAB_FAIL)); + } + + let streamSwapped = false; + return new Promise((resolve, reject) => { + const captureParams = mr.mirror.Service.createCaptureParameters_( + sourceUrn, mirrorSettings, opt_presentationId); + new mr.mirror.MirrorMediaStream(captureParams) + .start() + .then(stream => { + if (this.currentMediaStream_) { + this.currentMediaStream_.setOnStreamEnded(null); + this.currentMediaStream_.stop(); + this.recordStreamEnded(); + } + this.currentMediaStream_ = stream; + this.currentMediaStream_.setOnStreamEnded(this.cleanup_.bind(this)); + streamSwapped = true; + + if (opt_streamStartedCallback) { + return opt_streamStartedCallback(route).promise; + } + return route; + }) + .then(_ => { + if (!this.currentSession) { + throw new mr.mirror.Error('Session ended while updating stream'); + } + if (!this.currentMediaStream_) { + throw new mr.mirror.Error( + 'Media stream ended before session could be updated.'); + } + return this.currentSession.updateStream( + /** @type {!MediaStream} */ ( + this.currentMediaStream_.getMediaStream())); + }) + .then(this.postProcessMirroring_.bind( + this, route, sourceUrn, mirrorSettings)) + .then(() => resolve(route)) + .catch(err => { + this.onStartError_(/** @type {!Error} */ (err)); + if (streamSwapped) { + return this.cleanup_().then(() => { + reject(err); + }); + } else { + reject(err); + } + }); + }); + } + + /** + * @param {!mr.Route} route + * @param {string} sourceUrn + * @param {!mr.mirror.Settings} mirrorSettings + * @return {!Promise<void>} Resolves when done. + * @private + */ + postProcessMirroring_(route, sourceUrn, mirrorSettings) { + return new Promise((resolve, reject) => { + if (!this.currentSession) { + reject(new mr.mirror.Error( + 'Session gone before executing post-startup steps', + mr.MirrorAnalytics.CapturingFailure.TAB_FAIL)); + return; + } + if (mr.MediaSourceUtils.isTabMirrorSource(sourceUrn)) { + this.currentSession.setTabId( + /** @type {number} */ (route.mirrorInitData.tabId)); + this.recordTabMirrorStartSuccess(); + } else if (mr.MediaSourceUtils.isPresentationSource(sourceUrn)) { + this.currentSession.setTabId( + /** @type {number} */ (route.mirrorInitData.tabId)); + this.recordOffscreenTabMirrorStartSuccess(); + } else { + this.recordDesktopMirrorStartSuccess(); + } + this.checkCaptureIssues_( + mirrorSettings, + /** @type {!mr.mirror.MirrorMediaStream} */ + (this.currentMediaStream_)); + this.currentSession.onActivityUpdated(); + resolve(); + }); + } + + /** + * @param {!Error|!mr.mirror.Error} error The error that occurred when + * starting mirroring. + * @private + */ + onStartError_(error) { + error.reason = (error.reason != null) ? + error.reason : + mr.MirrorAnalytics.CapturingFailure.UNKNOWN; + + this.logger.error( + `Failed to start mirroring: ${error.message}` + + `, reason = ${error.reason}: ${error.stack}`); + this.recordMirrorStartFailure(error.reason); + } + + /** + * @return {!Promise<boolean>} Fulfilled with true if there was a session + * and it was stopped, and with false if there is no session to stop. + */ + stopCurrentMirroring() { + if (!this.initialized_) { + return Promise.reject('Not initialized'); + } + return this.cleanup_().then(hadSession => { + if (hadSession) this.recordMirrorSessionEnded(); + + return hadSession; + }); + } + + /** + * @return {!Promise<boolean>} Fulfilled with true if there was a session + * and it was stopped. This promise never rejects. + * @private + */ + cleanup_() { + // No-op if the listener was already removed. + chrome.tabs.onUpdated.removeListener(this.onTabUpdated_); + + const streamToCleanUp = this.currentMediaStream_; + this.currentMediaStream_ = null; + if (streamToCleanUp) { + // Clear the "on ended" callback to prevent recursive calls to this method + // while the session and MediaStream are being torn down (below). + streamToCleanUp.setOnStreamEnded(null); + } + + const sessionToCleanUp = this.currentSession; + this.currentSession = null; + + // Create a promise chain to execute the stopping of the session, invoke the + // before/after stop callbacks, and thereafter stop the MediaStream. The + // MediaStream is stopped after the session to avoid any extra churn in the + // session shutdown process. All of this is conditional on whether a session + // and/or MediaStream was ever started since cleanup_() is also used as an + // all-purpose failed-start recovery handler. + let cleanupPromise; + if (sessionToCleanUp) { + cleanupPromise = + this.beforeCleanUpSession(sessionToCleanUp) + .catch( + err => + this.logger.error('Error in before-cleanup steps', err)) + .then(() => sessionToCleanUp.stop()) + .catch(err => this.logger.error('Error stopping session', err)) + .then(() => { + this.mirrorServiceCallbacks_.onMirrorSessionEnded( + sessionToCleanUp.getRoute().id); + }) + .catch(err => this.logger.error('Error in ended callbacks', err)) + .then(() => true); + } else { + cleanupPromise = Promise.resolve(false); + } + if (streamToCleanUp) { + cleanupPromise = cleanupPromise.then(hadSession => { + streamToCleanUp.stop(); + this.recordStreamEnded(); + return hadSession; + }); + } + + return cleanupPromise; + } + + /** + * @param {!mr.mirror.Session} session the session about to be cleaned up. + * @return {!Promise<void>} Fulfilled when session has been cleaned up. + * @protected + */ + beforeCleanUpSession(session) { + return Promise.resolve(); + } + + /** + * Handles tab updated event. + * + * @param {number} tabId the ID of the tab. + * @param {!TabChangeInfo} changeInfo The changes to the state of the tab. + * @param {!Tab} tab The tab. + * @private + */ + handleTabUpdate_(tabId, changeInfo, tab) { + mr.EventAnalytics.recordEvent(mr.EventAnalytics.Event.TABS_ON_UPDATED); + this.currentSession && + this.currentSession.onTabUpdated(tabId, changeInfo, tab); + } + + /** + * Creates a new mr.mirror.CaptureParameters from the given inputs. + * + * @param {string} sourceUrn + * @param {!mr.mirror.Settings} mirrorSettings + * @param {string=} opt_presentationId + * @return {!mr.mirror.CaptureParameters} + * @private + */ + static createCaptureParameters_( + sourceUrn, mirrorSettings, opt_presentationId) { + if (mr.MediaSourceUtils.isTabMirrorSource(sourceUrn)) { + return new mr.mirror.CaptureParameters( + mr.mirror.CaptureSurfaceType.TAB, mirrorSettings); + } else if (mr.MediaSourceUtils.isDesktopMirrorSource(sourceUrn)) { + return new mr.mirror.CaptureParameters( + mr.mirror.CaptureSurfaceType.DESKTOP, mirrorSettings); + } else if (mr.MediaSourceUtils.isPresentationSource(sourceUrn)) { + if (!opt_presentationId) { + throw new mr.mirror.Error('Missing offscreen tab presentation id'); + } + return new mr.mirror.CaptureParameters( + mr.mirror.CaptureSurfaceType.OFFSCREEN_TAB, mirrorSettings, sourceUrn, + /** @type {!string} */ (opt_presentationId)); + } else { + throw new mr.mirror.Error( + 'Source URN does not suggest a known capture type.'); + } + } + + /** + * Checks for any capture issues after mirroring has started successfully. + * + * @param {!mr.mirror.Settings} settings The requested settings. + * @param {!mr.mirror.MirrorMediaStream} mediaStream The captured stream. + * @private + */ + checkCaptureIssues_(settings, mediaStream) { + if (settings.shouldCaptureAudio && mediaStream.getMediaStream() && + !mediaStream.getMediaStream().getAudioTracks().length) { + this.mirrorServiceCallbacks_.sendIssue(new mr.Issue( + mr.mirror.Messages.MSG_MR_MIRROR_NO_AUDIO_CAPTURED, + mr.IssueSeverity.NOTIFICATION)); + } + } + + /** + * @return {?mr.mirror.Session} + * @deprecated Use of this getter is dangerous, since mr.mirror.Service could + * be in the middle of a sequence of asynchronous steps to start up or + * shut down the current session. + */ + getCurrentSession() { + return this.currentSession; + } + + /** + * @param {!mr.mirror.Settings} mirrorSettings + * @param {!mr.Route} route + * @return {!mr.mirror.Session} + */ + createMirrorSession(mirrorSettings, route) {} + /** + * Records that Tab Mirroring successfully started. + */ + recordTabMirrorStartSuccess() {} + /** + * Records that Desktop Mirroring successfully started. + */ + recordDesktopMirrorStartSuccess() {} + /** + * Records that Offscreen Tab (1UA mode) Mirroring successfully started. + */ + recordOffscreenTabMirrorStartSuccess() {} + /** + * Records that the session has ended. + */ + recordMirrorSessionEnded() {} + /** + * Records that a Mirroring session failed to start. + * @param {mr.MirrorAnalytics.CapturingFailure} reason + * The reason for the failure. + */ + recordMirrorStartFailure(reason) {} + /** + * Records that a Mirroring stream ended. + */ + recordStreamEnded() {} + /** + * Asynchronously uploads logs for the most recent mirroring session. + * @param {string} feedbackId ID of feedback requesting log upload. + * @return {!Promise<string|undefined>} Resolved with an identifier (e.g., + * URL) of the log that will be uploaded (which will be undefined if there + * is no such identifier), or rejected if upload failed. + */ + requestLogUpload(feedbackId) { + return mr.Assertions.rejectNotImplemented(); + } + /** + * See documentation in interface_data/mojo.js. + * @param {string} routeId + * @param {!mojo.InterfaceRequest} controllerRequest + * @param {!mojo.MediaStatusObserverPtr} observer + * @return {!Promise<void>} + */ + createMediaRouteController(routeId, controllerRequest, observer) { + return mr.Assertions.rejectNotImplemented(); + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service_loader.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service_loader.js new file mode 100644 index 00000000000..4d0eefeb307 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service_loader.js @@ -0,0 +1,47 @@ +// 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. + +goog.provide('mr.mirror.DefaultServiceLoader'); +goog.provide('mr.mirror.ServiceLoader'); + + + +/** + * Loads a mirror.Service. Note that this loader does not need to handle event + * page suspending and waking up (the event page is running when there is a + * local mirroring route). + * @record + */ +mr.mirror.ServiceLoader = class { + /** + * Loads and returns the service. + * @return {!Promise<!mr.mirror.Service>} + */ + loadService() {} +}; + + +/** + * A lightweight implementation of ServiceLoader which just returns the instance + * provided to the constructor. + * @implements {mr.mirror.ServiceLoader} + */ +mr.mirror.DefaultServiceLoader = class { + /** + * @param {!mr.mirror.Service} serviceInstance + */ + constructor(serviceInstance) { + /** + * @private {!mr.mirror.Service} + */ + this.serviceInstance_ = serviceInstance; + } + + /** + * @override + */ + loadService() { + return Promise.resolve(this.serviceInstance_); + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service_name.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service_name.js new file mode 100644 index 00000000000..0a588727732 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_service_name.js @@ -0,0 +1,21 @@ +// 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. + +/** + * @fileoverview Service name uniquely identifying each service. + */ + + +goog.provide('mr.mirror.ServiceName'); + + +/** + * @enum {string} + */ +mr.mirror.ServiceName = { + CAST_STREAMING: 'cast_streaming', + HANGOUTS: 'hangouts', + MEETINGS: 'meetings', + WEBRTC: 'webrtc' +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_session.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_session.js new file mode 100644 index 00000000000..7c3a0a3442c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_session.js @@ -0,0 +1,179 @@ +// 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. + +/** + * @fileoverview Interface to a mirroring session. + + */ + +goog.provide('mr.mirror.Session'); + +goog.require('mr.TabUtils'); +goog.require('mr.mirror.Activity'); + + +/** + * Creates a new MirrorSession. Do not call, as this is an abstract base class. + */ +mr.mirror.Session = class { + /** + * @param {!mr.Route} route + * @param {?function(!mr.Route, !mr.mirror.Activity)=} onActivityUpdate + */ + constructor(route, onActivityUpdate = null) { + /** + * The media route associated with this mirror session. + * @protected @const {!mr.Route} + */ + this.route = route; + + /** + * Called when this.activity_ has changed. + * @private {?function(!mr.Route, !mr.mirror.Activity)} + */ + this.onActivityUpdate_ = onActivityUpdate; + + /** + * The activity description for keeping UI strings up to date. + * @protected {!mr.mirror.Activity} + */ + this.activity = mr.mirror.Activity.createFromRoute(route); + + /** @type {?number} */ + this.tabId = null; + + /** @type {?Tab} */ + this.tab = null; + + /** @type {boolean} */ + this.isRemoting = false; + } + + /** + * Sets the callback for handling activity updates. + * @param {!function(!mr.Route, !mr.mirror.Activity)} onActivityUpdate + */ + setOnActivityUpdate(onActivityUpdate) { + this.onActivityUpdate_ = onActivityUpdate; + } + + /** + * @param {number} tabId The id of the tab being captured, if any. + * @return {!Promise<void>} + */ + setTabId(tabId) { + if (this.tabId != tabId) { + this.tabId = tabId; + return mr.TabUtils.getTab(tabId).then((tab) => { + this.tab = tab; + this.onActivityUpdated(); + }); + } else { + return Promise.resolve(); + } + } + + /** + * Handles tab updated event. + * + * @param {number} tabId the ID of the tab. + * @param {!TabChangeInfo} changeInfo The changes to the state of the tab. + * @param {!Tab} tab The tab. + */ + onTabUpdated(tabId, changeInfo, tab) { + if (tabId != this.tabId) return; + if (changeInfo.status == 'complete' || + (!!changeInfo.favIconUrl && tab.status == 'complete')) { + this.tab = tab; + this.onActivityUpdated(); + } + } + + /** + * Called when the activity object for the mirror session needs to be updated. + * This happens when e.g. the tab being mirrored navigates, changes title, or + * switches in or out of media remoting. + */ + onActivityUpdated() { + if (this.tab) { + this.activity.setContentTitle(this.tab.title); + this.activity.setIncognito(this.tab.incognito); + const url = new URL(this.tab.url); + if (url.protocol == 'file:') { + this.activity.setType(mr.mirror.Activity.Type.MIRROR_FILE); + this.activity.setOrigin(null); + } else { + this.activity.setType( + this.isRemoting ? mr.mirror.Activity.Type.MEDIA_REMOTING : + mr.mirror.Activity.Type.MIRROR_TAB); + if (url.protocol == 'https:') { + // OK to drop the protocol for secure origins. + this.activity.setOrigin(url.origin.substr(8)); + } else { + this.activity.setOrigin(url.origin); + } + } + } + this.route.description = this.activity.getRouteDescription(); + this.onActivityUpdate_ && this.onActivityUpdate_(this.route, this.activity); + this.sendActivityToSink(); + } + + /** + * @return {!mr.Route} + */ + getRoute() { + return this.route; + } + + /** + * @return {!mr.mirror.Activity} + */ + getActivity() { + return this.activity; + } + + /** + * Starts the mirroring session. The |mediaStream| must provide one audio + * and/or one video track. It is illegal to call start() more than once on the + * same session, even after stop() has been called. See updateStream(), or + * else create a new instance to re-start a session. + * @param {!MediaStream} mediaStream The media stream that has the audio + * and/or video track. + * @return {!Promise<mr.mirror.Session>} Fulfilled when the + * session has been created and transports have started for the streams. + */ + start(mediaStream) {} + + /** + * @return {boolean} Whether the session supports updating its media stream + * while it is active. + */ + supportsUpdateStream() { + return false; + } + + /** + * Updates the media stream that the session is using. + * @param {!MediaStream} mediaStream + * @return {!Promise} + */ + updateStream(mediaStream) {} + + /** + * Stops the mirroring session. The underlying streams and transports are + * stopped and destroyed. Sessions cannot be re-started. Instead, either use + * updateStream() or create a new instance to re-start a session. + * @return {!Promise<void>} Fulfilled with once the session has been stopped. + * The promise is rejected on error. + */ + stop() { + return Promise.resolve(); + } + + /** + * Sends updated activity info directly to the sink. + */ + sendActivityToSink() {} +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_session_test.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_session_test.js new file mode 100644 index 00000000000..52f0536c11a --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_session_test.js @@ -0,0 +1,118 @@ +// 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. + +goog.setTestOnly(); +goog.require('mr.Route'); +goog.require('mr.TabUtils'); +goog.require('mr.mirror.Session'); + +describe('Tests mr.mirror.Session', () => { + let mirrorRoute; + let session; + let onActivityUpdated; + + function expectNormalSession(s) { + expect(s.tabId).toBe(47); + expect(s.tab).not.toBe(null); + expect(s.isRemoting).toBe(false); + expect(s.activity.getRouteDescription()) + .toBe('Casting tab (news.google.com)'); + expect(s.activity.getRouteMediaStatus()).toBe('Google News'); + expect(s.activity.getCastRemoteTitle()).toBe('Casting tab'); + } + + function expectIncognitoSession(s) { + expect(s.tabId).toBe(47); + expect(s.tab).not.toBe(null); + expect(s.isRemoting).toBe(false); + expect(s.activity.getRouteDescription()) + .toBe('Casting tab (news.google.com)'); + expect(s.activity.getRouteMediaStatus()).toBe('Google News'); + expect(s.activity.getCastRemoteTitle()).toBe('Casting active'); + } + + beforeEach(() => { + window['mojo'] = null; // Workaround to allow tests to run in Jasmine + // without mojo bindings + mirrorRoute = new mr.Route( + 'routeId', 'presentationId', 'sinkId', + 'urn:x-org.chromium.media:source:tab:47', true, null); + onActivityUpdated = jasmine.createSpy(); + session = new mr.mirror.Session(mirrorRoute, onActivityUpdated); + session.tabId = 47; + spyOn(session, 'sendActivityToSink'); + }); + + it('has default data for a tab mirroring route', () => { + expect(session.route).toBe(mirrorRoute); + expect(session.tabId).toBe(47); + expect(session.tab).toBe(null); + expect(session.isRemoting).toBe(false); + expect(session.activity.getRouteDescription()).toBe('Casting tab'); + expect(session.activity.getRouteMediaStatus()).toBe(''); + expect(session.activity.getCastRemoteTitle()).toBe('Casting tab'); + }); + + describe('onTabUpdated', () => { + it('sets all fields with normal tab', () => { + session.onTabUpdated(47, {'status': 'complete'}, { + 'title': 'Google News', + 'url': 'https://news.google.com', + 'incognito': false + }); + expect(session.sendActivityToSink).toHaveBeenCalled(); + expect(onActivityUpdated).toHaveBeenCalled(); + expectNormalSession(session); + }); + it('sets some fields with incognito tab', () => { + session.onTabUpdated(47, {'status': 'complete'}, { + 'title': 'Google News', + 'url': 'https://news.google.com', + 'incognito': true + }); + expect(session.sendActivityToSink).toHaveBeenCalled(); + expect(onActivityUpdated).toHaveBeenCalled(); + expectIncognitoSession(session); + }); + it('sets some fields with OTR route', () => { + mirrorRoute.offTheRecord = true; + otrSession = new mr.mirror.Session(mirrorRoute, onActivityUpdated); + otrSession.tabId = 47; + spyOn(otrSession, 'sendActivityToSink'); + otrSession.onTabUpdated(47, {'status': 'complete'}, { + 'title': 'Google News', + 'url': 'https://news.google.com', + 'incognito': true + }); + expect(otrSession.sendActivityToSink).toHaveBeenCalled(); + expect(onActivityUpdated).toHaveBeenCalled(); + expectIncognitoSession(otrSession); + }); + }); + + describe('setTabId', () => { + beforeEach((done) => { + spyOn(mr.TabUtils, 'getTab').and.returnValue(Promise.resolve({ + 'title': 'CNN', + 'url': 'https://www.cnn.com', + 'incognito': false + })); + session.setTabId(48); + done(); + }); + + it('updates the tab', (done) => { + expect(session.sendActivityToSink).toHaveBeenCalled(); + expect(onActivityUpdated).toHaveBeenCalled(); + expect(session.tabId).toBe(48); + expect(session.tab).not.toBe(null); + expect(session.isRemoting).toBe(false); + expect(session.activity.getRouteDescription()) + .toBe('Casting tab (www.cnn.com)'); + expect(session.activity.getRouteMediaStatus()).toBe('CNN'); + expect(session.activity.getCastRemoteTitle()).toBe('Casting tab'); + done(); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_settings.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_settings.js new file mode 100644 index 00000000000..74499bbacbc --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_settings.js @@ -0,0 +1,400 @@ +// 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. + +/** + * @fileoverview Tab-mirroring settings, including video bitrate, video + * resolution, and audio bitrate. + * + + */ + +goog.provide('mr.mirror.Settings'); +goog.provide('mr.mirror.VideoCodec'); + +goog.require('mr.Logger'); +goog.require('mr.PlatformUtils'); + + +/** + * @enum {string} + */ +mr.mirror.VideoCodec = { + VP8: 'VP8', + // This is the internal codename for hardware-encoded H264. for V1 mirroring. + CAST1: 'CAST1', + H264: 'H264', + + // This is a fake codec for retransmissions used in WebRTC. + RTX: 'rtx' +}; + + + +/** + * Settings that affect capture and transport (via Cast Streaming or WebRTC). + * Generally, these should all be left unchanged from their defaults. + * Overriding them is only meant for development or user experiments. + * + * WARNING: If making any changes to defaults here, it is on *you* to confirm + * that all downstream consumers of these settings will behave correctly with + * the new values. If not, see usage notes below for per-sink adjustments. + * + * Usage: Generally, this class should be instantiated by mr.Provider + * implementations only. The provider creates a new instance, and then must + * adjust any properties based on both the sender's and sink's capabilities. + * It should also, as a final step, freeze the settings object to prevent any + * downstream code from making changes. Example provider code: + * + * getMirrorSettings(sinkId) { + * // Constrain default settings to a sink that is only capable of standard + * // definition (lower resolution and no high frame rate support). + * const settings = new mr.mirror.Settings(); + * settings.maxWidth = Math.min(settings.maxWidth, 640); + * settings.maxHeight = Math.min(settings.maxWidth, 360); + * settings.maxFrameRate = Math.min(settings.maxFrameRate, 30); + * + * // Override: Some sinks might require the sender to do the letterboxing. + * settings.senderSideLetterboxing = + * !this.canSinkHandleLetterboxing_(sinkId); + * + * settings.makeFinalAdjustmentsAndFreeze(); + * return settings; + * } + * + */ +mr.mirror.Settings = class { + constructor() { + /** + * Maximum video width in pixels. + * + * @export {number} + */ + this.maxWidth = 1920; + + /** + * Maximum video height in pixels. + * + * @export {number} + */ + this.maxHeight = 1080; + + /** + * Minimum video width in pixels. + * + * @export {number} + */ + this.minWidth = 180; + + /** + * Minimum video height in pixels. + * + * @export {number} + */ + this.minHeight = 180; + + /** + * Whether the screen capture must handle letterboxing/pillarboxing. If + * false (more desired), the receiver will handle it. When setting this to + * true, please see comments for getMinDimensionsToMatchAspectRatio(). + * + * @export {boolean} + */ + this.senderSideLetterboxing = false; + + /** + * The minimum frame rate for captures. Well-behaved clients can handle a + * minimum frame rate of zero, which prevents wasting system resources + * sender-side. Unfortunately, not all clients are well-behaved... + * + * @export {number} + */ + this.minFrameRate = 0; + + /** + * The maximum frame rate for captures. + * + * @export {number} + */ + this.maxFrameRate = 30; + + /** + * Minimum video bitrate in kbps. + * + * @export {number} + */ + this.minVideoBitrate = 300; + + /** + * Maximum video bitrate in kbps. + * + * @export {number} + */ + this.maxVideoBitrate = 5000; + + /** + * Target audio bitrate in kbps (0 means automatic). + * + * @export {number} + */ + this.audioBitrate = 0; + + /** + * Maximum end-to-end latency (in milliseconds). + * + * @export {number} + */ + this.maxLatencyMillis = 800; + + /** + * Minimum end-to-end latency (in milliseconds). This allows cast streaming + * to adaptively lower latency in interactive streaming scenarios. + * This setting currently applies to cast streaming only. + * + + * + * @export {number} + */ + this.minLatencyMillis = 400; + + /** + * Starting end-to-end latency for animated content (in milliseconds). + * + * @export {number} + */ + this.animatedLatencyMillis = 400; + + /** + * Enable DSCP? + * This setting currently applies to cast streaming only. + * + * @export {boolean} + */ + this.dscpEnabled = + [ + mr.PlatformUtils.OS.MAC, mr.PlatformUtils.OS.LINUX, + mr.PlatformUtils.OS.CHROMEOS + ].includes(mr.PlatformUtils.getCurrentOS()) || + mr.PlatformUtils.isWindows8OrNewer(); + + /** + * Whether to enable network transport logging. + * + * @export {boolean} + */ + this.enableLogging = true; + + /** + * Whether an attempt should be made to use TDLS. + * This setting currently applies to cast streaming only. + * + * @export {boolean} + */ + this.useTdls = false; + + + /** + * Whether video should be captured. This could be influenced by the + * application and/or the sink's capabilities. + * @export {boolean} + */ + this.shouldCaptureVideo = true; + + /** + * Whether audio should be captured. This could be influenced by the + * application and/or the sink's capabilities. + * @export {boolean} + */ + this.shouldCaptureAudio = true; + + // For development, debugging, or integration testing use only! + const overrides = window.localStorage ? + window.localStorage.getItem(mr.mirror.Settings.OverridesKey) : + null; + if (overrides) { + try { + const parsedOverrides = JSON.parse(String(overrides)); + if (parsedOverrides instanceof Object) { + this.update_(/** @type {!Object} */ (parsedOverrides)); + mr.Logger.getInstance('mr.mirror.Settings') + .warning( + () => 'Initial mr.mirror.Settings overridden to: ' + + this.toJsonString()); + } else { + throw Error( + `localStorage[${mr.mirror.Settings.OverridesKey}] ` + + `does not parse as an Object: ${overrides}`); + } + } catch (exception) { + mr.Logger.getInstance('mr.mirror.Settings') + .error( + mr.mirror.Settings.OverridesKey + ' must be of the form ' + + '\'{"maxWidth":640, "maxHeight":360}\'.', + exception); + // Prevent mirroring from starting if overrides are present and not + // parseable. + throw new Error('Overrides not parseable. See ERROR log for details.'); + } + } + } + + /** + * @return {!mr.mirror.Settings} + */ + clone() { + const settings = new mr.mirror.Settings(); + settings.update_(this); + return settings; + } + + /** + * Returns the properties of this Settings object as a JSON-formatted string. + * @return {!string} + */ + toJsonString() { + return JSON.stringify(this, (key, value) => { + // Only public fields are included in the stringified output. + if (key.length == 0 || !key.endsWith('_')) { + return value; + } + return undefined; + }); + } + + /** + * Update this object to have the same settings as another object. + * @param {!Object} settings The properties to apply, which may be any subset + * of all the public fields of this class. + * @private + */ + update_(settings) { + // Override Closure-compiler "access on a struct" error. + const self = /** @type {!Object} */ (this); + for (const key of Object.keys(settings)) { + if (key.endsWith('_') || (typeof settings[key] !== typeof self[key])) { + continue; + } + self[key] = settings[key]; + } + } + + /** + * Make mandatory system-wide bounds adjustments and then freeze this Settings + * object. See example usage in class-level comments. + */ + makeFinalAdjustmentsAndFreeze() { + this.clampMaxDimensionsToScreenSize_(); + Object.freeze(this); + } + + /** + * Adjusts the maxWidth/maxHeight to within the size of the user's screen, and + * rounds down to a standard 16:9 resolution (i.e., width is 0 modulo 160 and + * height is 0 modulo 90). This prevents performance problems due to: + * 1. The pre-capture fullscreen size being something way larger than the + * system was designed for (e.g., 1080p on a Daisy Chromebook). + * 2. The receiver dealing with scaling from an oddball resolution to a + * standard resolution (e.g., 1366x768 --> 1280x720). + * @private + */ + clampMaxDimensionsToScreenSize_() { + const widthStep = 160; + const heightStep = 90; + const screenWidth = mr.mirror.Settings.getScreenWidth(); + const screenHeight = mr.mirror.Settings.getScreenHeight(); + const x = this.maxWidth * screenHeight; + const y = this.maxHeight * screenWidth; + let clampedWidth = 0; + let clampedHeight = 0; + if (y < x) { + clampedWidth = Math.min(this.maxWidth, screenWidth); + clampedWidth = clampedWidth - (clampedWidth % widthStep); + clampedHeight = clampedWidth * heightStep / widthStep; + } else { + clampedHeight = Math.min(this.maxHeight, screenHeight); + clampedHeight = clampedHeight - (clampedHeight % heightStep); + clampedWidth = clampedHeight * widthStep / heightStep; + } + if (clampedWidth < Math.max(widthStep, this.minWidth) || + clampedHeight < Math.max(heightStep, this.minHeight)) { + clampedWidth = Math.max(widthStep, this.minWidth); + clampedHeight = Math.max(heightStep, this.minHeight); + } + + this.maxWidth = clampedWidth; + this.maxHeight = clampedHeight; + } + + /** + * Returns alternate |minWidth| and |minHeight| values that match the aspect + * ratio of |maxWidth| and |maxHeight| to the nearest integer. Some of the + * capture APIs will then interpret the matching aspect ratios to mean that + * the sender should letterbox/pillarbox the video, rather than allowing the + * receiver to handle it. This method does NOT modify any properties of this + * Settings object. + * @return {!{width: number, height: number}} New minimum width/height values, + * as described. + */ + getMinDimensionsToMatchAspectRatio() { + if (this.minAndMaxAspectRatiosAreSimilar()) { + return {width: this.minWidth, height: this.minHeight}; + } + + let a = this.maxWidth; + let b = this.maxHeight; + while (b != 0) { + const remainder = a % b; + a = b; + b = remainder; + } + // Note: |a| now contains the greatest common divisor. + let width = this.maxWidth / a; + let height = this.maxHeight / a; + if (width < this.minWidth || height < this.minHeight) { + // Increase to respect the current min width/Height setting. + const upFactor = Math.max(this.minWidth / width, this.minHeight / height); + width *= upFactor; + height *= upFactor; + // ...and make sure the increase does not now exceed the max width/height. + if (width > this.maxWidth || height > this.maxHeight) { + width = this.maxWidth; + height = this.maxHeight; + } + } + width = Math.round(width); + height = Math.round(height); + return {width, height}; + } + + /** + * @return {boolean} Returns true if the aspect ratios of the min and max + * dimensions are within one part in one hundred of each other. + */ + minAndMaxAspectRatiosAreSimilar() { + if (this.minHeight == 0 || this.maxHeight == 0) { + return false; + } + // Source: content/renderer/media/media_stream_video_capturer_source.cc (in + // Chromium project, circa 2016). + const ratioOfMinSize = Math.floor(100.0 * this.minWidth / this.minHeight); + const ratioOfMaxSize = Math.floor(100.0 * this.maxWidth / this.maxHeight); + return ratioOfMinSize == ratioOfMaxSize; + } + + /** @return {number} */ + static getScreenWidth() { + return screen.width; + } + + /** @return {number} */ + static getScreenHeight() { + return screen.height; + } +}; + + +/** + * The key for retrieving settings overrides from localStorage. + * @type {string} + */ +mr.mirror.Settings.OverridesKey = 'mr.mirror.Settings.Overrides'; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_settings_test.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_settings_test.js new file mode 100644 index 00000000000..066e92f3198 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/mirror_settings_test.js @@ -0,0 +1,123 @@ +// 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. + +goog.require('mr.mirror.Settings'); + +describe('Tests Mirror Settings', function() { + it('produces JSON strings containing public fields', function() { + // Emit Settings as a JSON-format string. + const settings = new mr.mirror.Settings(); + const jsonString = settings.toJsonString(); + + // The string should be parseable as JSON and contain only public fields. + const parsed = JSON.parse(jsonString); + expect(typeof parsed['maxWidth']).toBe('number'); + expect(typeof parsed['enableLogging']).toBe('boolean'); + expect(Object.keys(parsed).sort()) + .toEqual(Object.keys(settings).filter(x => !x.endsWith('_')).sort()); + }); + + it('only updates public fields from localStorage overrides', function() { + const settings = new mr.mirror.Settings(); + + // Normal case: A public field is updated with a new value of the same type. + const originalMaxWidth = settings.maxWidth; + settings.update_({'maxWidth': originalMaxWidth / 2}); + expect(settings.maxWidth).toBe(originalMaxWidth / 2); + + // Bad case: A public field being set to a value of a different type. + const jsonBefore = settings.toJsonString(); + settings.update_({'maxWidth': true}); + expect(settings.toJsonString()).toEqual(jsonBefore); + + // Bad case: A non-existant field. + settings.update_({'fooey': true}); + expect(settings.toJsonString()).toEqual(jsonBefore); + + // Bad case: Attempt to set private field. + const loggerBefore = settings.logger_; + const injectionAttackFunc = function() { + return 'MUAHAHAHAHA!'; + }; + settings.update_({'logger_': injectionAttackFunc}); + expect(settings.logger_).not.toBe(injectionAttackFunc); + expect(settings.logger_).toBe(loggerBefore); + expect(settings.toJsonString()).toEqual(jsonBefore); + }); + + it('clamps max dimensions to 1920x1080 screen size', function() { + spyOn(mr.mirror.Settings, 'getScreenWidth').and.returnValue(1920); + spyOn(mr.mirror.Settings, 'getScreenHeight').and.returnValue(1080); + const settings = new mr.mirror.Settings(); + settings.makeFinalAdjustmentsAndFreeze(); + expect(settings.maxWidth).toBe(1920); + expect(settings.maxHeight).toBe(1080); + }); + + it('clamps max dimensions to 1366x768 screen size', function() { + spyOn(mr.mirror.Settings, 'getScreenWidth').and.returnValue(1366); + spyOn(mr.mirror.Settings, 'getScreenHeight').and.returnValue(768); + const settings = new mr.mirror.Settings(); + settings.makeFinalAdjustmentsAndFreeze(); + expect(settings.maxWidth).toBe(1280); + expect(settings.maxHeight).toBe(720); + }); + + it('returns min size matching aspect ratio of max size', function() { + const settings = new mr.mirror.Settings(); + settings.maxWidth = 1920; + settings.maxHeight = 1080; + settings.minWidth = 320; + settings.minHeight = 180; + expect(settings.minAndMaxAspectRatiosAreSimilar()).toBe(true); + expect(settings.getMinDimensionsToMatchAspectRatio()) + .toEqual({width: 320, height: 180}); + + settings.minWidth = 0; + settings.minHeight = 0; + expect(settings.minAndMaxAspectRatiosAreSimilar()).toBe(false); + expect(settings.getMinDimensionsToMatchAspectRatio()) + .toEqual({width: 16, height: 9}); + settings.minWidth = 1; + settings.minHeight = 1; + expect(settings.minAndMaxAspectRatiosAreSimilar()).toBe(false); + expect(settings.getMinDimensionsToMatchAspectRatio()) + .toEqual({width: 16, height: 9}); + settings.minWidth = 16; + settings.minHeight = 9; + expect(settings.minAndMaxAspectRatiosAreSimilar()).toBe(true); + + settings.minWidth = 320; + settings.minHeight = 240; + expect(settings.minAndMaxAspectRatiosAreSimilar()).toBe(false); + expect(settings.getMinDimensionsToMatchAspectRatio()) + .toEqual({width: 427, height: 240}); + + settings.maxWidth = 1000; + settings.maxHeight = 1000; + settings.minWidth = 48; + settings.minHeight = 27; + expect(settings.minAndMaxAspectRatiosAreSimilar()).toBe(false); + expect(settings.getMinDimensionsToMatchAspectRatio()) + .toEqual({width: 48, height: 48}); + + settings.maxWidth = 1001; + settings.maxHeight = 999; + settings.minWidth = 0; + settings.minHeight = 0; + expect(settings.getMinDimensionsToMatchAspectRatio()) + .toEqual({width: 1001, height: 999}); + }); + + it('is frozen after final adjustments are made', function() { + 'use strict'; + const settings = new mr.mirror.Settings(); + expect(Object.isFrozen(settings)).toBe(false); + settings.makeFinalAdjustmentsAndFreeze(); + expect(Object.isFrozen(settings)).toBe(true); + expect(() => { + settings.maxWidth = 999; + }).toThrow(); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/capture_parameters.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/capture_parameters.js new file mode 100644 index 00000000000..dbf1d77fa8c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/capture_parameters.js @@ -0,0 +1,208 @@ +// 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. + +/** + * @fileoverview Stream capture parameters. + */ + +goog.provide('mr.mirror.CaptureParameters'); +goog.provide('mr.mirror.CaptureSurfaceType'); + +goog.require('mr.Assertions'); +goog.require('mr.mirror.Config'); + + +/** + * Parameters that configure and control local media capture. + */ +mr.mirror.CaptureParameters = class { + /** + * @param {!mr.mirror.CaptureSurfaceType} captureSurface + * @param {!mr.mirror.Settings} mirrorSettings + * @param {string=} opt_offscreenTabUrl + * @param {string=} opt_presentationId + */ + constructor( + captureSurface, mirrorSettings, opt_offscreenTabUrl, opt_presentationId) { + if (opt_offscreenTabUrl) { + mr.Assertions.assert( + captureSurface == mr.mirror.CaptureSurfaceType.OFFSCREEN_TAB); + mr.Assertions.assert(opt_presentationId); + } + + /** @type {!mr.mirror.CaptureSurfaceType} */ + this.captureSurface = captureSurface; + + /** @type {!mr.mirror.Settings} */ + this.mirrorSettings = mirrorSettings; + + /** @type {?string} */ + this.offscreenTabUrl = opt_offscreenTabUrl || null; + + /** @type {?string} */ + this.presentationId = opt_presentationId || null; + } + + /** + * @return {boolean} True if this is for tab capture + */ + isTab() { + return this.captureSurface == mr.mirror.CaptureSurfaceType.TAB; + } + + /** + * @return {boolean} True if this is for desktop capture + */ + isDesktop() { + return this.captureSurface == mr.mirror.CaptureSurfaceType.DESKTOP; + } + + /** + * @return {boolean} True if this is for offscreen tab capture + */ + isOffscreenTab() { + return this.captureSurface == mr.mirror.CaptureSurfaceType.OFFSCREEN_TAB; + } + + /** + * @param {string=} sourceId The source ID of the desktop media. + * @return {!MediaStreamConstraints|!Object} Media constraints for use with + * platform capture APIs. + */ + toMediaConstraints(sourceId = undefined) { + if (this.isTab()) { + return this.toTabMediaConstraints_(); + } else if (this.isOffscreenTab()) { + return this.toOffscreenTabMediaConstraints_(); + } else { + return this.toDesktopMediaConstraints_(sourceId); + } + } + + /** + * @return {!MediaConstraints} Media constraints for use with platform + * capture APIs. + * @private + */ + toTabMediaConstraints_() { + + // + // Also the tabCapture API shape doesn't match getUserMedia, wich combines + // audio and video constraints into a single dictionary. + // + // Both of these issues make it hard to type toMediaConstraints() properly. + const constraints = /** @type {!MediaConstraints} */ + ({ + 'audio': this.mirrorSettings.shouldCaptureAudio, + 'video': this.mirrorSettings.shouldCaptureVideo + }); + + if (this.mirrorSettings.shouldCaptureVideo) { + constraints['videoConstraints'] = { + 'mandatory': {'enableAutoThrottling': true} + }; + this.setCommonVideoConstraints_( + constraints['videoConstraints']['mandatory']); + } + return constraints; + } + + /** + * @return {!MediaConstraints} Media constraints for use with offscreen + * capture APIs. + * @private + */ + toOffscreenTabMediaConstraints_() { + mr.Assertions.assert( + this.presentationId, 'Missing offscreen capture presentation id'); + const constraints = this.toTabMediaConstraints_(); + constraints['presentationId'] = this.presentationId; + return constraints; + }; + + /** + * @param {string=} sourceId The source id of the desktop media. Only required + * if capturing video. + * @return {!MediaStreamConstraints} Media constraints for use with platform + * capture APIs. + * @private + */ + toDesktopMediaConstraints_(sourceId = undefined) { + const constraints = /** @type {!MediaStreamConstraints} */ + ({'audio': false, 'video': false}); + + if (this.mirrorSettings.shouldCaptureVideo) { + mr.Assertions.assert(sourceId); + constraints['video'] = { + 'mandatory': { + 'chromeMediaSource': 'desktop', + 'chromeMediaSourceId': sourceId, + } + }; + this.setCommonVideoConstraints_(constraints['video']['mandatory']); + + if (mr.mirror.Config.isDesktopAudioCaptureAvailable && + this.mirrorSettings.shouldCaptureAudio) { + // NOTE(mfoltz): Nothing in Chrome seems to consume the audio sourceId; + // however, continuing to pass it in case something changes in the + // future. + constraints['audio'] = { + 'mandatory': { + 'chromeMediaSource': 'system', + 'chromeMediaSourceId': sourceId, + } + }; + } + } else if (this.mirrorSettings.shouldCaptureAudio) { + mr.Assertions.assert(!sourceId); + mr.Assertions.assert(mr.mirror.Config.isDesktopAudioCaptureAvailable); + constraints['audio'] = { + 'mandatory': { + 'chromeMediaSource': 'system', + } + }; + } + + return constraints; + } + + /** + * Helper to populate common video constraints fields for both capture APIs. + * @param {!MediaStreamConstraints|!MediaConstraints} constraints + * @private + */ + setCommonVideoConstraints_(constraints) { + // If sender-side letterboxing is being used, pass altered min dimensions to + // the capture API which match the aspect ratio of the max dimensions. The + // capture API will interpret this to mean that it must perform + // letterboxing/pillarboxing. + let minWidth = this.mirrorSettings.minWidth; + let minHeight = this.mirrorSettings.minHeight; + if (this.mirrorSettings.senderSideLetterboxing) { + const altMin = this.mirrorSettings.getMinDimensionsToMatchAspectRatio(); + minWidth = altMin.width; + minHeight = altMin.height; + } + + Object.assign(constraints, { + 'minWidth': minWidth, + 'minHeight': minHeight, + 'maxWidth': this.mirrorSettings.maxWidth, + 'maxHeight': this.mirrorSettings.maxHeight, + 'minFrameRate': this.mirrorSettings.minFrameRate, + 'maxFrameRate': this.mirrorSettings.maxFrameRate, + }); + } +}; + + +/** + * Possible capture modes. + * @enum {string} + */ +mr.mirror.CaptureSurfaceType = { + TAB: 'tab', + DESKTOP: 'desktop', + OFFSCREEN_TAB: 'offscreen_tab' +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/mirror_media_stream.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/mirror_media_stream.js new file mode 100644 index 00000000000..370b91fcb15 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/mirror_media_stream.js @@ -0,0 +1,340 @@ +// 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. + +/** + * @fileoverview Media stream for use with tab and desktop capture. + * + * A media stream has a video and/or audio track. Each capture source (i.e., + * tab) should use its own MediaStream object and call start() to initiate + * capture. + */ + +goog.provide('mr.mirror.MirrorMediaStream'); + +goog.require('mr.Assertions'); +goog.require('mr.Logger'); +goog.require('mr.MirrorAnalytics'); +goog.require('mr.PlatformUtils'); +goog.require('mr.mirror.Config'); +goog.require('mr.mirror.Error'); + +/** + * Constructs a new MediaStream that will capture media according to + * captureParams. + */ +mr.mirror.MirrorMediaStream = class { + /** + * @param {!mr.mirror.CaptureParameters} captureParams + */ + constructor(captureParams) { + /** @private {!mr.mirror.CaptureParameters} */ + this.captureParams_ = captureParams; + + /** @private {?function()} */ + this.onStreamEnded_ = null; + + /** @private {?MediaStream} */ + this.mediaStream_ = null; + + /** @private {mr.Logger} */ + this.logger_ = mr.Logger.getInstance('mr.mirror.MirrorMediaStream'); + } + + /** + * @param {?function()} onStreamEnded Invoked when the stream fires an + * onended event. + */ + setOnStreamEnded(onStreamEnded) { + this.onStreamEnded_ = onStreamEnded; + } + + /** + * @return {!mr.mirror.CaptureParameters} + */ + getCaptureParams() { + return this.captureParams_; + } + + /** + * @return {?MediaStream} + */ + getMediaStream() { + return this.mediaStream_; + } + + /** + * Starts capturing media and sets audioTrack and videoTrack. + * @return {!Promise<!mr.mirror.MirrorMediaStream>} Fulfilled when capture + * has started. + */ + start() { + if (this.captureParams_.isTab()) { + return this.startTabCapturing_(); + } else if (this.captureParams_.isOffscreenTab()) { + return this.startOffscreenTabCapturing_(); + } else { + return this.startDesktopCapturing_(); + } + } + + /** + * @return {!Promise<!mr.mirror.MirrorMediaStream>} Fulfilled when capture + * has started. + * @private + */ + startTabCapturing_() { + const constraints = this.captureParams_.toMediaConstraints(); + this.logger_.info( + 'Starting tab capture with constraints ' + JSON.stringify(constraints)); + + return new Promise((resolve, reject) => { + // Note: There's a subtle reason to NOT pass |resolve| as the + // callback function to the chrome.tabCapture.capture() call here. + // Because this is an extension API, when an error occurs, the value + // in chrome.runtime.lastError is only available during the call to + // the callback function. However, the Promise.then() method runs + // its function at a later time, when chrome.runtime.lastError is no + // longer set. + chrome.tabCapture.capture(constraints, stream => { + if (stream) { + this.setStream_(stream); + resolve(this); + } else { + reject(this.createTabCaptureError_()); + } + }); + + // Set a timer to reject the promise after a delay. + // + + window.setTimeout(() => { + // In normal usage, this will be a no-op because this promise will + // already have been resolved by the call to + // chrome.tabCapture.capture above. + reject(new mr.mirror.Error( + 'chrome.tabCapture.capture failed to call its callback', + mr.MirrorAnalytics.CapturingFailure.CAPTURE_TAB_TIMEOUT)); + }, mr.mirror.MirrorMediaStream.TAB_CAPTURE_TIMEOUT_); + }); + } + + /** + * @param {?Event} event The ended event. + * @private + */ + handleTrackEnded_(event) { + if (event) { + this.logger_.info( + () => 'Track ' + JSON.stringify(event.target) + ' ended'); + } + this.stop(); + } + + /** + * Returns an Error corresponding to a chrome.tabCapture error. + * @return {!mr.mirror.Error} The corresponding error. + * @private + */ + createTabCaptureError_() { + // As of Chrome 51, when |stream| is null, chrome.runtime.lastError.message + // should always be set to a non-empty string. If it is not, fall back to + // the default error message so everyone can yell at miu@. + if (chrome.runtime.lastError && chrome.runtime.lastError.message) { + return new mr.mirror.Error( + chrome.runtime.lastError.message, + mr.MirrorAnalytics.CapturingFailure.TAB_FAIL); + } else { + return new mr.mirror.Error( + mr.mirror.MirrorMediaStream.EMPTY_STREAM_, + mr.MirrorAnalytics.CapturingFailure.CAPTURE_TAB_FAIL_EMPTY_STREAM); + } + } + + /** + * Requests a screen capture source from the user via a native dialog and + * returns the source ID, or rejects if a timeout is reached or the user + * cancels. + * @param {number=} timeoutMillis The timeout in milliseconds. + * @return {!Promise<string>} Fulfilled with the source ID. + * @private + */ + requestScreenCaptureSourceId_( + timeoutMillis = mr.mirror.MirrorMediaStream.WINDOW_PICKER_TIMEOUT_) { + return new Promise((resolve, reject) => { + const desktopChooserConfig = ['screen', 'audio']; + if (mr.PlatformUtils.getCurrentOS() == mr.PlatformUtils.OS.LINUX) { + desktopChooserConfig.push('window'); + } + let requestId; + // Wait 60 seconds and then cancel the picker and reject the + // promise. + + const timeoutId = window.setTimeout(() => { + if (requestId) { + chrome.desktopCapture.cancelChooseDesktopMedia(requestId); + } + reject(new mr.mirror.Error( + 'timeout', + mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_TIMEOUT)); + }, timeoutMillis); + // https://developer.chrome.com/extensions/desktopCapture#method-chooseDesktopMedia + requestId = chrome.desktopCapture.chooseDesktopMedia( + desktopChooserConfig, sourceId => { + window.clearTimeout(timeoutId); + if (!sourceId) { + // User cancelled the desktop media selector prompt. + reject(new mr.mirror.Error( + 'User cancelled capture dialog', + mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL)); + } else { + resolve(sourceId); + } + }); + }); + } + + /** + * Generates a screen capture MediaStream from the given + * MediaStreamConstraints. + * @param {!MediaStreamConstraints} constraints The constraints object to use. + * @return {!Promise<MediaStream>} Fulfilled with the MediaStream from + * capture. + * @private + */ + generateScreenCaptureStream_(constraints) { + return new Promise((resolve, reject) => { + this.logger_.info( + () => 'Starting desktop capture with constraints ' + + JSON.stringify(constraints)); + navigator.mediaDevices.getUserMedia(constraints) + .then( + stream => { + if (!stream) { + // NOTE(miu): This implies that getUserMedia is broken, and it + // may also be breaking chrome.tabCapture. + reject(new mr.mirror.Error( + mr.mirror.MirrorMediaStream.EMPTY_STREAM_, + mr.MirrorAnalytics.CapturingFailure.DESKTOP_FAIL)); + } + this.setStream_(stream); + resolve(stream); + }, + error => { + let errorReason = + mr.MirrorAnalytics.CapturingFailure.DESKTOP_FAIL; + // Certain errors indicate the user cancelled the request. + // https://www.w3.org/TR/mediacapture-streams/#methods-5 + if (error.name == 'NotAllowedError') { + errorReason = mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL; + } + reject(new mr.mirror.Error( + `${error.name} ${error.constraintName}: ${error.message}`, + errorReason)); + }); + }); + } + + /** + * @return {!Promise<!mr.mirror.MirrorMediaStream>} Fulfilled when capture + * has started. + * @private + */ + startDesktopCapturing_() { + if (mr.mirror.Config.isDesktopAudioCaptureAvailable && + this.captureParams_.mirrorSettings.shouldCaptureAudio && + !this.captureParams_.mirrorSettings.shouldCaptureVideo) { + return this + .generateScreenCaptureStream_( + this.captureParams_.toMediaConstraints()) + .then(_ => this); + } + + // Video capture requires asking the user to pick which screen to capture. + + return this.requestScreenCaptureSourceId_().then(sourceId => { + const constraints = this.captureParams_.toMediaConstraints(sourceId); + return this.generateScreenCaptureStream_(constraints).then(_ => this); + }); + } + + /** + * @return {!Promise<!mr.mirror.MirrorMediaStream>} Fulfilled when capture + * has started. + * @private + */ + startOffscreenTabCapturing_() { + mr.Assertions.assert(!!this.captureParams_.offscreenTabUrl); + const constraints = this.captureParams_.toMediaConstraints(); + this.logger_.info( + () => 'Starting offscreen tab capture with constraints ' + + JSON.stringify(constraints)); + return new Promise((resolve, reject) => { + chrome.tabCapture.captureOffscreenTab( + this.captureParams_.offscreenTabUrl.toString(), constraints, + stream => { + if (stream) { + this.setStream_(stream); + resolve(this); + } else { + reject(this.createTabCaptureError_()); + } + }); + }); + } + + /** + * @param {!MediaStream} stream + * @private + */ + setStream_(stream) { + this.mediaStream_ = stream; + mr.Assertions.assert( + stream.getAudioTracks().length || stream.getVideoTracks().length, + 'Expecting at least one audio or video track.'); + // For desktop capturing, users may stop capturing via desktop capturing's + // own stop button, which triggers onended event. + stream.getTracks().forEach(track => { + track.onended = this.handleTrackEnded_.bind(this); + }); + } + + /** + * Stops captured streams. + */ + stop() { + if (!this.mediaStream_) return; + this.mediaStream_.getTracks().forEach(track => { + track.onended = null; + track.stop(); + }); + this.mediaStream_ = null; + if (this.onStreamEnded_) { + this.onStreamEnded_(); + } + } +}; + + +/** + * The number of milliseconds to wait after for the browser to call the callback + * function after calling chrome.tabCapture.capture. + * @private @const {number} + */ +mr.mirror.MirrorMediaStream.TAB_CAPTURE_TIMEOUT_ = 5000; + + +/** + * @private @const {number} + */ +mr.mirror.MirrorMediaStream.WINDOW_PICKER_TIMEOUT_ = 60000; + + +/** + * Error messaging for reporting of empty streams. + * @private @const {string} + */ +mr.mirror.MirrorMediaStream.EMPTY_STREAM_ = 'empty_stream'; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/mirror_media_stream_test.js b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/mirror_media_stream_test.js new file mode 100644 index 00000000000..45746847799 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mirror_services/stream_capture/mirror_media_stream_test.js @@ -0,0 +1,407 @@ +// 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. + +goog.setTestOnly(); + +goog.require('mr.PlatformUtils'); +goog.require('mr.mirror.CaptureParameters'); +goog.require('mr.mirror.CaptureSurfaceType'); +goog.require('mr.mirror.Config'); +goog.require('mr.mirror.Error'); +goog.require('mr.mirror.MirrorMediaStream'); +goog.require('mr.mirror.Settings'); + +describe('mr.mirror.MirrorMediaStream', () => { + let captureParams; + let instance; + let mediaStream; + let mirrorSettings; + + beforeEach(() => { + chrome.runtime.lastError = null; + chrome.tabCapture = + jasmine.createSpyObj('tabCapture', ['capture', 'captureOffscreenTab']); + chrome.desktopCapture = jasmine.createSpyObj( + 'desktopCapture', ['chooseDesktopMedia', 'cancelChooseDesktopMedia']); + spyOn(navigator.mediaDevices, 'getUserMedia'); + spyOn(mr.PlatformUtils, 'getCurrentOS'); + + mediaStream = jasmine.createSpyObj( + 'mediaStream', ['getAudioTracks', 'getVideoTracks', 'getTracks']); + mirrorSettings = new mr.mirror.Settings(); + captureParams = new mr.mirror.CaptureParameters( + mr.mirror.CaptureSurfaceType.DESKTOP, mirrorSettings); + instance = new mr.mirror.MirrorMediaStream(captureParams); + jasmine.clock().install(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('accessor for capture params returns initial value', () => { + expect(instance.getCaptureParams()).toBe(captureParams); + }); + + describe('when capturing a tab', () => { + beforeEach(() => { + captureParams.captureSurface = mr.mirror.CaptureSurfaceType.TAB; + }); + + it('stores the stream upon successful capture', (done) => { + mediaStream.getVideoTracks.and.returnValue([{}]); + mediaStream.getAudioTracks.and.returnValue([{}]); + mediaStream.getTracks.and.returnValue([{}]); + chrome.tabCapture.capture.and.callFake((constraints, callback) => { + callback(mediaStream); + }); + + instance.start() + .then(() => { + expect(chrome.tabCapture.capture).toHaveBeenCalled(); + expect(instance.getMediaStream()).toBe(mediaStream); + done(); + }) + .catch(fail); + }); + + it('rejects with an error upon empty stream with error message', (done) => { + chrome.runtime.lastError = {message: 'expected-message'}; + chrome.tabCapture.capture.and.callFake((constraints, callback) => { + callback(); + }); + + instance.start().catch((err) => { + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason).toBe(mr.MirrorAnalytics.CapturingFailure.TAB_FAIL); + expect(err.message).toBe('expected-message'); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + + it('rejects with an error upon empty stream with empty message', (done) => { + chrome.tabCapture.capture.and.callFake((constraints, callback) => { + callback(); + }); + + instance.start().catch((err) => { + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure + .CAPTURE_TAB_FAIL_EMPTY_STREAM); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + + it('rejects with an error upon timeout', (done) => { + instance.start().catch((err) => { + expect(window.chrome.tabCapture.capture).toHaveBeenCalled(); + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure.CAPTURE_TAB_TIMEOUT); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + jasmine.clock().tick(5001); + }); + }); + + describe('when capturing an off-screen tab', () => { + beforeEach(() => { + captureParams.offscreenTabUrl = 'offscreen-tab-url'; + captureParams.presentationId = 'offscreen-tab-presentation-id'; + captureParams.captureSurface = mr.mirror.CaptureSurfaceType.OFFSCREEN_TAB; + }); + + it('stores the stream upon successful capture', (done) => { + mediaStream.getVideoTracks.and.returnValue([{}]); + mediaStream.getAudioTracks.and.returnValue([{}]); + mediaStream.getTracks.and.returnValue([{}]); + chrome.tabCapture.captureOffscreenTab.and.callFake( + (tabUrl, constraints, callback) => { + expect(tabUrl).toBe('offscreen-tab-url'); + callback(mediaStream); + }); + + instance.start() + .then(() => { + expect(window.chrome.tabCapture.captureOffscreenTab) + .toHaveBeenCalled(); + expect(instance.getMediaStream()).toBe(mediaStream); + done(); + }) + .catch(fail); + }); + + it('rejects with an error upon empty stream with error message', (done) => { + chrome.runtime.lastError = {message: 'expected-message'}; + chrome.tabCapture.captureOffscreenTab.and.callFake( + (tabUrl, constraints, callback) => callback()); + + instance.start().catch((err) => { + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason).toBe(mr.MirrorAnalytics.CapturingFailure.TAB_FAIL); + expect(err.message).toBe('expected-message'); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + + it('rejects with an error upon empty stream with empty message', (done) => { + chrome.tabCapture.captureOffscreenTab.and.callFake( + (tabUrl, constraints, callback) => callback()); + + instance.start().catch((err) => { + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure + .CAPTURE_TAB_FAIL_EMPTY_STREAM); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + }); + + describe('when capturing the desktop', () => { + const supportsAudioCapture = + mr.mirror.Config.isDesktopAudioCaptureAvailable; + + beforeEach(() => { + captureParams.captureSurface = mr.mirror.CaptureSurfaceType.DESKTOP; + }); + + afterEach(() => { + mr.mirror.Config.isDesktopAudioCaptureAvailable = supportsAudioCapture; + }); + + it('stores the audio and video streams upon successful capture', (done) => { + mediaStream.getVideoTracks.and.returnValue([{}]); + mediaStream.getAudioTracks.and.returnValue([{}]); + mediaStream.getTracks.and.returnValue([{}]); + + chrome.desktopCapture.chooseDesktopMedia.and.callFake( + (config, callback) => { + callback('source-id'); + }); + navigator.mediaDevices.getUserMedia.and.callFake( + constraints => Promise.resolve(mediaStream)); + + instance.start() + .then(() => { + expect(chrome.desktopCapture.chooseDesktopMedia).toHaveBeenCalled(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); + expect(instance.getMediaStream()).toBe(mediaStream); + done(); + }) + .catch(fail); + }); + + it('stores the audio stream upon successful capture', (done) => { + mr.mirror.Config.isDesktopAudioCaptureAvailable = true; + const audioOnlyMirrorSettings = new mr.mirror.Settings(); + audioOnlyMirrorSettings.shouldCaptureVideo = false; + const audioOnlyCaptureParams = new mr.mirror.CaptureParameters( + mr.mirror.CaptureSurfaceType.DESKTOP, audioOnlyMirrorSettings); + const audioOnlyInstance = + new mr.mirror.MirrorMediaStream(audioOnlyCaptureParams); + + mediaStream.getVideoTracks.and.returnValue([{}]); + mediaStream.getAudioTracks.and.returnValue([{}]); + mediaStream.getTracks.and.returnValue([{}]); + + navigator.mediaDevices.getUserMedia.and.callFake( + constraints => Promise.resolve(mediaStream)); + + audioOnlyInstance.start() + .then(() => { + expect(chrome.desktopCapture.chooseDesktopMedia) + .not.toHaveBeenCalled(); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled(); + expect(audioOnlyInstance.getMediaStream()).toBe(mediaStream); + done(); + }) + .catch(fail); + }); + + it('allows choosing only screen, audio for non-linux platforms', (done) => { + mr.PlatformUtils.getCurrentOS.and.returnValue( + mr.PlatformUtils.OS.WINDOWS); + chrome.desktopCapture.chooseDesktopMedia.and.callFake( + (config, callback) => { + expect(config).toContain('screen'); + expect(config).toContain('audio'); + expect(config).not.toContain('window'); + done(); + }); + instance.start(); + }); + + it('allows choosing screen, audio, window for linux platforms', (done) => { + mr.PlatformUtils.getCurrentOS.and.returnValue(mr.PlatformUtils.OS.LINUX); + chrome.desktopCapture.chooseDesktopMedia.and.callFake( + (config, callback) => { + expect(config).toContain('screen'); + expect(config).toContain('audio'); + expect(config).toContain('window'); + done(); + }); + instance.start(); + }); + + it('rejects with an error upon timeout in desktop chooser', (done) => { + chrome.desktopCapture.chooseDesktopMedia.and.returnValue('expected-id'); + + instance.start().catch((err) => { + expect(chrome.desktopCapture.chooseDesktopMedia).toHaveBeenCalled(); + expect(chrome.desktopCapture.cancelChooseDesktopMedia) + .toHaveBeenCalledWith('expected-id'); + expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled(); + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_TIMEOUT); + expect(err.message).toBe('timeout'); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + + jasmine.clock().tick(60001); + }); + + it('rejects with an error when user cancels desktop picker', (done) => { + chrome.desktopCapture.chooseDesktopMedia.and.callFake( + (config, callback) => { + callback(/* no source id */); + }); + + instance.start().catch((err) => { + expect(chrome.desktopCapture.chooseDesktopMedia).toHaveBeenCalled(); + expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled(); + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL); + expect(err.message).toMatch(/cancelled/i); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + + it('rejects with an error upon getUserMedia error', (done) => { + chrome.desktopCapture.chooseDesktopMedia.and.callFake( + (config, callback) => { + callback('source-id'); + }); + + navigator.mediaDevices.getUserMedia.and.callFake( + constraints => Promise.reject( + new DOMException('expected-message', 'SecurityError'))); + + instance.start().catch((err) => { + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure.DESKTOP_FAIL); + expect(err.message).toMatch(/\bSecurityError\b/); + expect(err.message).toMatch(/\bexpected-message\b/); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + + it('rejects with an cancelled error upon NotAllowedError', (done) => { + chrome.desktopCapture.chooseDesktopMedia.and.callFake( + (config, callback) => { + callback('source-id'); + }); + navigator.mediaDevices.getUserMedia.and.callFake( + constraints => Promise.reject( + new DOMException('expected-message', 'NotAllowedError'))); + + instance.start().catch((err) => { + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure + .CAPTURE_DESKTOP_FAIL_ERROR_USER_CANCEL); + expect(err.message).toMatch(/\bNotAllowedError\b/); + expect(err.message).toMatch(/\bexpected-message\b/); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + + it('rejects with an error upon empty stream object', (done) => { + chrome.desktopCapture.chooseDesktopMedia.and.callFake( + (config, callback) => { + callback('source-id'); + }); + navigator.mediaDevices.getUserMedia.and.callFake( + constraints => Promise.resolve(null)); + + instance.start().catch((err) => { + expect(err instanceof mr.mirror.Error).toBe(true); + expect(err.reason) + .toBe(mr.MirrorAnalytics.CapturingFailure.DESKTOP_FAIL); + expect(instance.getMediaStream()).toBe(null); + done(); + }); + }); + }); + + describe('when stopping a stream', () => { + let startPromise; + let track; + + beforeEach(() => { + track = jasmine.createSpyObj('track', ['stop']); + mediaStream.getVideoTracks.and.returnValue([{}]); + mediaStream.getAudioTracks.and.returnValue([{}]); + mediaStream.getTracks.and.returnValue([track]); + captureParams.captureSurface = mr.mirror.CaptureSurfaceType.TAB; + chrome.tabCapture.capture.and.callFake((constraints, callback) => { + callback(mediaStream); + }); + + startPromise = instance.start(); + }); + + it('calls stop() on the tracks', (done) => { + startPromise + .then(() => { + instance.stop(); + expect(track.stop).toHaveBeenCalled(); + expect(track.onended).toBe(null); + expect(instance.getMediaStream()).toBe(null); + done(); + }) + .catch(fail); + }); + + it('automatically stops when track ends', (done) => { + startPromise + .then(() => { + track.onended(); + expect(track.stop).toHaveBeenCalled(); + expect(track.onended).toBe(null); + expect(instance.getMediaStream()).toBe(null); + done(); + }) + .catch(fail); + }); + + it('calls the onStreamEnded callback if it exists', (done) => { + const onStreamEndedSpy = jasmine.createSpy('onStreamEnded'); + instance.setOnStreamEnded(onStreamEndedSpy); + + startPromise + .then(() => { + instance.stop(); + expect(onStreamEndedSpy).toHaveBeenCalled(); + done(); + }) + .catch(fail); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/module.js b/chromium/chrome/browser/resources/media_router/extension/src/module.js new file mode 100644 index 00000000000..248c6cb6097 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/module.js @@ -0,0 +1,247 @@ +// 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. + +/** + * @fileoverview Contains definition for modules and the module loader. + * The Media Router extension is logically separated into modules. Each module + * and their corresponding bundle path are registered at startup + * time. When a module is required, they will be loaded on-demand. + */ + +goog.provide('mr.Module'); +goog.provide('mr.ModuleId'); + +goog.require('mr.Logger'); +goog.require('mr.PromiseResolver'); + + +/** + * Identifier for each module. + * @enum {string} + */ +mr.ModuleId = { + CAST_CHANNEL_SERVICE: 'mr.cast.ChannelService', + CAST_SINK_DISCOVERY_SERVICE: 'mr.cast.SinkDiscoveryService', + CAST_STREAMING_SERVICE: 'mr.mirror.cast.Service', + SLARTI_SINK_DISCOVERY_SERVICE: 'mr.cloud.slarti.SinkDiscoveryService', + WEAVE_SINK_DISCOVERY_SERVICE: 'mr.cloud.discovery.WeaveSinkDiscoveryService', + HANGOUTS_SERVICE: 'mr.mirror.hangouts.HangoutsService', + MEETINGS_SERVICE: 'mr.mirror.hangouts.MeetingsService', + PROVIDER_MANAGER: 'mr.ProviderManager', + WEBRTC_STREAMING_SERVICE: 'mr.mirror.webrtc.WebRtcService' +}; + + +/** + * Identifier for bundles. A bundle is a js file containing a collection of + * modules. + + * @enum {string} + */ +mr.Bundle = { + MAIN: 'background_script.js', + MIRRORING_CAST_STREAMING: 'mirroring_cast_streaming.js', + MIRRORING_HANGOUTS: 'mirroring_hangouts.js', + MIRRORING_WEBRTC: 'mirroring_webrtc.js' +}; + + +/** + * Base class for a module. When a module is loaded and initialized, it should + * call mr.Module.onModuleLoaded to inform its dependencies that it is ready. + */ +mr.Module = class { + /** + * Returns the module with the given ID if it is already initialized, null + * otherwise. + * @param {mr.ModuleId} moduleId + * @return {?mr.Module} + */ + static get(moduleId) { + return mr.Module.moduleById_.get(moduleId) || null; + } + + /** + * Loads the module with the given ID. If the module is already loaded, the + * Promise is resolved immediately. + * @param {mr.ModuleId} moduleId + * @return {!Promise<!mr.Module>} Resolved with the module when + * it is loaded. + */ + static load(moduleId) { + const module = mr.Module.get(moduleId); + if (module) { + return Promise.resolve(module); + } + let resolver = mr.Module.resolverByModuleId_.get(moduleId); + if (!resolver) { + resolver = new mr.PromiseResolver(); + mr.Module.resolverByModuleId_.set(moduleId, resolver); + mr.Module.loadBundleForModule_(moduleId, resolver); + } + + return resolver.promise; + } + + /** + * Loads the bundle corresponding to the given module. Called the first time + * a module is requested. + * @param {mr.ModuleId} moduleId + * @param {!mr.PromiseResolver<!mr.Module>} resolver Rejected if the bundle + * associated with the module won't be loaded due to permanent error. + * @private + */ + static loadBundleForModule_(moduleId, resolver) { + const bundle = mr.Module.getBundle_(moduleId); + if (!bundle) { + resolver.reject(new Error(`No corresponding bundle for ${moduleId}`)); + return; + } + + if (mr.Module.DEFAULT_LOAD_BUNDLES_.has(bundle)) { + return; + } + + // Check if the bundle has been not requested previously. + let bundlePromise = mr.Module.bundlePromises_.get(bundle); + if (!bundlePromise) { + + mr.Module.logger_.info(`Loading bundle ${bundle} for module ${moduleId}`); + bundlePromise = mr.Module.doLoadBundle_(bundle); + mr.Module.bundlePromises_.set(bundle, bundlePromise); + } + + // Add an error handler to reject the module load request. + bundlePromise.catch(e => { + resolver.reject(e); + }); + } + + /** + * Returns the bundle associated with a module. + * @param {mr.ModuleId} moduleId + * @return {?mr.Bundle} + * @private + */ + static getBundle_(moduleId) { + return mr.Module.MODULE_TO_BUNDLE_MAPPING_.get(moduleId) || null; + } + + /** + * Called when a bundle needs to be loaded. + * @param {mr.Bundle} bundle Name of bundle to load. + * @return {!Promise<void>} Resolves when the bundle is loaded or rejected if + * it failed to load. + * @private + */ + static doLoadBundle_(bundle) { + let resolver = new mr.PromiseResolver(); + resolver.promise.then( + () => { + mr.Module.logger_.info(`Bundle ${bundle} loaded`); + }, + e => { + mr.Module.logger_.error(`Failed to load bundle ${bundle}`); + throw e; + }); + let script = document.createElement('script'); + script.src = chrome.extension.getURL(bundle); + script.setAttribute('type', 'text/javascript'); + script.async = true; + + script.onload = () => resolver.resolve(undefined); + script.onerror = () => + resolver.reject(new Error(`Failed to load bundle ${bundle}`)); + document.head.appendChild(script); + return resolver.promise; + } + + /** + * Called by a module when it is done loading and initializing. Registers the + * module and resolves the outstanding promise returned by |load(moduleId)|. + * @param {mr.ModuleId} moduleId Identifier of the module. No two modules can + * have the same identifier. + * @param {!mr.Module} module The module that is ready. + */ + static onModuleLoaded(moduleId, module) { + if (mr.Module.moduleById_.has(moduleId)) { + throw new Error('Duplicate module ' + moduleId); + } + mr.Module.moduleById_.set(moduleId, module); + const resolver = mr.Module.resolverByModuleId_.get(moduleId); + if (resolver) { + resolver.resolve(module); + } + } + + /** + * Used for testing only. + */ + static clearForTest() { + mr.Module.moduleById_.clear(); + mr.Module.resolverByModuleId_.clear(); + mr.Module.bundlePromises_.clear(); + } + + /** + * Subclasses should override this if a mr.EventListener designated this + * module to forward the events to. + * @param {*} event The event delivered to the handler. It is the handler's + * responsibility to verify that it can handle the event. + * @param {...*} args Arguments for the event. + */ + handleEvent(event, ...args) { + throw new Error('Not implemented'); + } +}; + + +/** + * Maps a module ID to a bundle ID. Used for loading the bundle that contains + * a required module. + * @private @const {!Map<mr.ModuleId, mr.Bundle>} + */ +mr.Module.MODULE_TO_BUNDLE_MAPPING_ = new Map([ + [mr.ModuleId.CAST_CHANNEL_SERVICE, mr.Bundle.MAIN], + [mr.ModuleId.CAST_SINK_DISCOVERY_SERVICE, mr.Bundle.MAIN], + [mr.ModuleId.CAST_STREAMING_SERVICE, mr.Bundle.MIRRORING_CAST_STREAMING], + [mr.ModuleId.SLARTI_SINK_DISCOVERY_SERVICE, mr.Bundle.MAIN], + [mr.ModuleId.WEAVE_SINK_DISCOVERY_SERVICE, mr.Bundle.MAIN], + [mr.ModuleId.HANGOUTS_SERVICE, mr.Bundle.MIRRORING_HANGOUTS], + [mr.ModuleId.MEETINGS_SERVICE, mr.Bundle.MIRRORING_HANGOUTS], + [mr.ModuleId.PROVIDER_MANAGER, mr.Bundle.MAIN], + [mr.ModuleId.WEBRTC_STREAMING_SERVICE, mr.Bundle.MIRRORING_WEBRTC] +]); + + +/** + * Set of bundles that are loaded by default. + * @private @const {!Set<mr.Bundle>} + */ +mr.Module.DEFAULT_LOAD_BUNDLES_ = new Set([mr.Bundle.MAIN]); + + +/** @private {mr.Logger} */ +mr.Module.logger_ = mr.Logger.getInstance('mr.Module'); + +/** + * The set of modules currently loaded and initialized in the extension, keyed + * by their IDs. + * @private {!Map<mr.ModuleId, !mr.Module>} + */ +mr.Module.moduleById_ = new Map(); + + +/** + * Holds the outstanding promise while a module is being loaded. + * @private {!Map<mr.ModuleId, !mr.PromiseResolver<!mr.Module>>} + */ +mr.Module.resolverByModuleId_ = new Map(); + + +/** + * Holds the outstanding promise while a bundle is being loaded. + * @private {!Map<mr.Bundle, !Promise<void>>} + */ +mr.Module.bundlePromises_ = new Map(); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/module_test.js b/chromium/chrome/browser/resources/media_router/extension/src/module_test.js new file mode 100644 index 00000000000..1d2fed8c118 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/module_test.js @@ -0,0 +1,90 @@ +// 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. + +goog.setTestOnly('module_test'); + +goog.require('mr.Module'); +goog.require('mr.PromiseUtils'); + + + +describe('Tests modules', function() { + let mockModule; + + beforeEach(function() { + mockModule = {'id': 1}; + }); + + afterEach(function() { + mr.Module.clearForTest(); + }); + + it('gets a module before and after it is loaded', function() { + let module = mr.Module.get('SomeModule'); + expect(module).toBeNull(); + + const mockModule2 = {'id': 2}; + mr.Module.onModuleLoaded('AnotherModule', mockModule2); + + module = mr.Module.get('SomeModule'); + expect(module).toBeNull(); + + mr.Module.onModuleLoaded('SomeModule', mockModule); + module = mr.Module.get('SomeModule'); + expect(module).toBe(mockModule); + + module = mr.Module.get('AnotherModule'); + expect(module).toBe(mockModule2); + }); + + it('loads a module which loads a bundle', function(done) { + spyOn(mr.Module, 'getBundle_').and.returnValue('SomeBundle'); + spyOn(mr.Module, 'doLoadBundle_').and.returnValue(Promise.resolve()); + + const promise = mr.Module.load('SomeModule'); + const promise2 = mr.Module.load('SomeModule'); + + expect(mr.Module.getBundle_).toHaveBeenCalledWith('SomeModule'); + expect(mr.Module.doLoadBundle_).toHaveBeenCalledWith('SomeBundle'); + expect(mr.Module.doLoadBundle_.calls.count()).toBe(1); + + mr.Module.onModuleLoaded('SomeModule', mockModule); + + const promise3 = mr.Module.load('SomeModule'); + Promise.all([promise, promise2, promise3]).then(modules => { + for (let module of modules) { + expect(module).toBe(mockModule); + } + done(); + }); + }); + + it('load rejects if failed to load a bundle', function(done) { + spyOn(mr.Module, 'getBundle_').and.returnValue('SomeBundle'); + spyOn(mr.Module, 'doLoadBundle_') + .and.returnValue(Promise.reject(new Error('failed to load bundle'))); + + const promise = mr.Module.load('SomeModule'); + const promise2 = mr.Module.load('SomeModule'); + + expect(mr.Module.getBundle_).toHaveBeenCalledWith('SomeModule'); + expect(mr.Module.doLoadBundle_).toHaveBeenCalledWith('SomeBundle'); + expect(mr.Module.doLoadBundle_.calls.count()).toBe(1); + + const promise3 = mr.Module.load('SomeModule'); + mr.PromiseUtils.allSettled([promise, promise2, promise3]).then(results => { + for (let result of results) { + expect(result.fulfilled).toBe(false); + } + done(); + }); + }); + + it('each module is mapped to a bundle', () => { + for (const key in mr.ModuleId) { + expect(mr.Module.getBundle_(mr.ModuleId[key])) + .not.toBeNull(`${mr.ModuleId[key]} does not map to a bundle`); + } + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/mojo_externs.js b/chromium/chrome/browser/resources/media_router/extension/src/mojo_externs.js new file mode 100644 index 00000000000..93f4805ccde --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/mojo_externs.js @@ -0,0 +1,522 @@ +// 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. + +/** + * @fileoverview Closure definitions of Mojo service objects. Note that these + * definitions are bound only after chrome.mojoPrivate.requireAsync resolves + * in mojo.js. + */ + + +var mojo = {}; + + + +// Core Mojo. + +/** + * @param {!Object} interfaceType + * @param {!Object} impl + * @param {mojo.InterfaceRequest=} request + * @constructor + */ +mojo.Binding = function(interfaceType, impl, request) {}; + + +/** + * Closes the message pipe. The bound object will no longer receive messages. + */ +mojo.Binding.prototype.close = function() {}; + + +/** + * Binds to the given request. + * @param {!mojo.InterfaceRequest} request + */ +mojo.Binding.prototype.bind = function(request) {}; + + +/** @param {function()} callback */ +mojo.Binding.prototype.setConnectionErrorHandler = function(callback) {}; + + +/** + * Creates an interface ptr and bind it to this instance. + * @return {?} The interface ptr. + */ +mojo.Binding.prototype.createInterfacePtrAndBind = function() {}; + + + +/** @constructor */ +mojo.InterfaceRequest = function() {}; + + +/** + * Closes the message pipe. The object can no longer be bound to an + * implementation. + */ +mojo.InterfaceRequest.prototype.close = function() {}; + + + +/** @constructor */ +mojo.InterfacePtrController = function() {}; + + +/** + * Closes the message pipe. Messages can no longer be sent with this object. + */ +mojo.InterfacePtrController.prototype.reset = function() {}; + + +/** @param {function()} callback */ +mojo.InterfacePtrController.prototype.setConnectionErrorHandler = function( + callback) {}; + + + +/** + * @param {!Object} interfacePtr + */ +mojo.makeRequest = function(interfacePtr) {}; + + +// Mojom structs. + +/** + * @constructor + * @struct + */ +mojo.IPAddress = function() {}; + + + +/** @type {!Array<number>} */ +mojo.IPAddress.prototype.address; + + +/** @type {!Array<number>} */ +mojo.IPAddress.prototype.address_bytes; + + + +/** + * @constructor + * @struct + */ +mojo.IPEndPoint = function() {}; + + +/** @type {mojo.IPAddress} */ +mojo.IPEndPoint.prototype.address; + + +/** @type {number} */ +mojo.IPEndPoint.prototype.port; + + + +/** @constructor */ +mojo.Url = function() {}; + + +/** @type {string} */ +mojo.Url.prototype.url; + + + +/** + * @constructor + * @struct + */ +mojo.DialMediaSink = function() {}; + + +/** @type {mojo.IPAddress} */ +mojo.DialMediaSink.prototype.ip_address; + + +/** @type {string} */ +mojo.DialMediaSink.prototype.model_name; + + +/** @type {mojo.Url} */ +mojo.DialMediaSink.prototype.app_url; + + + +/** + * @constructor + * @struct + */ +mojo.CastMediaSink = function() {}; + + + +/** @type {mojo.IPAddress} */ +mojo.CastMediaSink.prototype.ip_address; + + +/** @type {mojo.IPEndPoint} */ +mojo.CastMediaSink.prototype.ip_endpoint; + + +/** @type {string} */ +mojo.CastMediaSink.prototype.model_name; + + +/** @type {number} */ +mojo.CastMediaSink.prototype.capabilities; + + +/** @type {number} */ +mojo.CastMediaSink.prototype.cast_channel_id; + + + +/** + * @constructor + * @struct + */ +mojo.SinkExtraData = function() {}; + + +/** @type {mojo.DialMediaSink} */ +mojo.SinkExtraData.prototype.dial_media_sink; + + +/** @type {mojo.CastMediaSink} */ +mojo.SinkExtraData.prototype.cast_media_sink; + + + +/** + * @constructor + * @struct + */ +mojo.Sink = function() {}; + + +/** @constructor */ +mojo.SinkIconType = function() {}; +mojo.SinkIconType.CAST = 0; +mojo.SinkIconType.CAST_AUDIO_GROUP = 1; +mojo.SinkIconType.CAST_AUDIO = 2; +mojo.SinkIconType.MEETING = 3; +mojo.SinkIconType.HANGOUT = 4; +mojo.SinkIconType.EDUCATION = 5; +mojo.SinkIconType.GENERIC = 6; + + +/** @type {string} */ +mojo.Sink.prototype.sink_id; + + +/** @type {string} */ +mojo.Sink.prototype.name; + + +/** @type {?string} */ +mojo.Sink.prototype.description; + + +/** @type {?string} */ +mojo.Sink.prototype.domain; + + +/** @type {?mojo.SinkIconType} */ +mojo.Sink.prototype.icon_type; + + +/** @type {?mojo.SinkExtraData} */ +mojo.Sink.prototype.extra_data; + + + +/** + * @param {!Object} values An object mapping from property names to values. + * @constructor + * @struct + */ +mojo.TimeDelta = function(values) {}; + + +/** @type {number} */ +mojo.TimeDelta.prototype.microseconds; + + + +/** + * @param {!Object} values An object mapping from property names to values. + * @constructor + * @struct + */ +mojo.MediaStatus = function(values) {}; + + +/** @constructor */ +mojo.MediaStatus.PlayState = function() {}; +/** @type {!mojo.MediaStatus.PlayState} */ +mojo.MediaStatus.PlayState.PLAYING; +/** @type {!mojo.MediaStatus.PlayState} */ +mojo.MediaStatus.PlayState.PAUSED; +/** @type {!mojo.MediaStatus.PlayState} */ +mojo.MediaStatus.PlayState.BUFFERING; + + +/** @type {?string} */ +mojo.MediaStatus.prototype.title; + + +/** @type {?string} */ +mojo.MediaStatus.prototype.description; + + +/** @type {boolean} */ +mojo.MediaStatus.prototype.can_play_pause; + + +/** @type {boolean} */ +mojo.MediaStatus.prototype.can_mute; + + +/** @type {boolean} */ +mojo.MediaStatus.prototype.can_set_volume; + + +/** @type {boolean} */ +mojo.MediaStatus.prototype.can_seek; + + +/** @type {?mojo.MediaStatus.PlayState} */ +mojo.MediaStatus.prototype.play_state; + + +/** @type {boolean} */ +mojo.MediaStatus.prototype.is_muted; + + +/** @type {number} */ +mojo.MediaStatus.prototype.volume; + + +/** @type {?mojo.TimeDelta} */ +mojo.MediaStatus.prototype.duration; + + +/** @type {?mojo.TimeDelta} */ +mojo.MediaStatus.prototype.current_time; + + +/** @type {mojo.HangoutsMediaStatusExtraData} */ +mojo.MediaStatus.prototype.hangouts_extra_data; + + + +/** + * @param {!Object} values + * @constructor + * @struct + */ +mojo.HangoutsMediaStatusExtraData = function(values) {}; + + +/** @type {boolean} */ +mojo.HangoutsMediaStatusExtraData.prototype.local_present; + + + +/** + * @param {!Object} values An object mapping from property names to values. + * @constructor + * @struct + */ +mojo.Origin = function(values) {}; + + +/** @type {string} */ +mojo.Origin.prototype.scheme; + + +/** @type {string} */ +mojo.Origin.prototype.host; + + +/** @type {number} */ +mojo.Origin.prototype.port; + + +/** @type {string} */ +mojo.Origin.prototype.suborigin; + + +/** @type {boolean} */ +mojo.Origin.prototype.unique; + + + +/** @constructor */ +mojo.RouteControllerType = function() {}; +/** @type {mojo.RouteControllerType} */ +mojo.RouteControllerType.kNone; +/** @type {mojo.RouteControllerType} */ +mojo.RouteControllerType.kGeneric; +/** @type {mojo.RouteControllerType} */ +mojo.RouteControllerType.kHangouts; +/** @type {mojo.RouteControllerType} */ +mojo.RouteControllerType.kMirroring; + + +// Mojom interfaces. + +/** + * @constructor + * @struct + */ +mojo.MediaStatusObserverPtr = function() {}; + + +/** @param {!mojo.MediaStatus} status */ +mojo.MediaStatusObserverPtr.prototype.onMediaStatusUpdated = function(status) { +}; + + +/** @type {!mojo.InterfacePtrController} */ +mojo.MediaStatusObserverPtr.prototype.ptr; + + + +/** @type {!Object} */ +mojo.MediaController; + + +/** @type {!Object} */ +mojo.HangoutsMediaRouteController; + + +/** @constructor */ +mojo.MediaRouteProviderConfig = function() {}; + + +/** @type {boolean} */ +mojo.MediaRouteProviderConfig.prototype.enable_dial_discovery; + + +/** @type {boolean} */ +mojo.MediaRouteProviderConfig.prototype.enable_dial_sink_query; + + +/** @type {boolean} */ +mojo.MediaRouteProviderConfig.prototype.enable_cast_discovery; + + + +/** @constructor */ +mojo.RemotingStopReason = function() {}; +/** @type {!mojo.RemotingStopReason} */ +mojo.RemotingStopReason.ROUTE_TERMINATED; +/** @type {!mojo.RemotingStopReason} */ +mojo.RemotingStopReason.USER_DISABLED; + + + +/** @constructor */ +mojo.RemotingSinkFeature = function() {}; + + + +/** @constructor */ +mojo.RemotingSinkAudioCapability = function() {}; +/** @type {!mojo.RemotingSinkAudioCapability} */ +mojo.RemotingSinkAudioCapability.CODEC_BASELINE_SET; +/** @type {!mojo.RemotingSinkAudioCapability} */ +mojo.RemotingSinkAudioCapability.CODEC_AAC; +/** @type {!mojo.RemotingSinkAudioCapability} */ +mojo.RemotingSinkAudioCapability.CODEC_OPUS; + + + +/** @constructor */ +mojo.RemotingSinkVideoCapability = function() {}; +/** @type {!mojo.RemotingSinkVideoCapability} */ +mojo.RemotingSinkVideoCapability.SUPPORT_4K; +/** @type {!mojo.RemotingSinkVideoCapability} */ +mojo.RemotingSinkVideoCapability.CODEC_BASELINE_SET; +/** @type {!mojo.RemotingSinkVideoCapability} */ +mojo.RemotingSinkVideoCapability.CODEC_H264; +/** @type {!mojo.RemotingSinkVideoCapability} */ +mojo.RemotingSinkVideoCapability.CODEC_VP8; +/** @type {!mojo.RemotingSinkVideoCapability} */ +mojo.RemotingSinkVideoCapability.CODEC_VP9; +/** @type {!mojo.RemotingSinkVideoCapability} */ +mojo.RemotingSinkVideoCapability.CODEC_HEVC; + + + +/** + * @constructor + * @struct + */ +mojo.RemotingSinkMetadata = function() {}; + + +/** @type {!Array<mojo.RemotingSinkFeature>} */ +mojo.RemotingSinkMetadata.prototype.features; + + +/** @type {!Array<mojo.RemotingSinkAudioCapability>} */ +mojo.RemotingSinkMetadata.prototype.audio_capabilities; + + +/** @type {!Array<mojo.RemotingSinkVideoCapability>} */ +mojo.RemotingSinkMetadata.prototype.video_capabilities; + + +/** @type {!string} */ +mojo.RemotingSinkMetadata.prototype.friendly_name; + + + +/** @type {!Object} */ +mojo.MirrorServiceRemoter; + + + +/** + * @constructor + */ +mojo.MirrorServiceRemoterPtr = function() {}; + + +/** @type {!mojo.InterfacePtrController} */ +mojo.MirrorServiceRemoterPtr.prototype.ptr; + + + +/** + * @constructor + */ +mojo.MirrorServiceRemotingSourcePtr = function() {}; + + +/** @param {!mojo.RemotingSinkMetadata} capabilities */ +mojo.MirrorServiceRemotingSourcePtr.prototype.onSinkAvailable = function( + capabilities) {}; + +/** @param {!Uint8Array} message */ +mojo.MirrorServiceRemotingSourcePtr.prototype.onMessageFromSink = function( + message) {}; + + +/** @param {!mojo.RemotingStopReason} reason */ +mojo.MirrorServiceRemotingSourcePtr.prototype.onStopped = function(reason) {}; + + +/** Notifies the remoting source when streaming error occurs. */ +mojo.MirrorServiceRemotingSourcePtr.prototype.onError = function() {}; + + +/** @type {!mojo.InterfacePtrController} */ +mojo.MirrorServiceRemotingSourcePtr.prototype.ptr; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/persistent_data.js b/chromium/chrome/browser/resources/media_router/extension/src/persistent_data.js new file mode 100644 index 00000000000..a7490f357e6 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/persistent_data.js @@ -0,0 +1,463 @@ +// 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. + +/** + * @fileoverview API for classes to save data and cleanup before the event page + * is shut down. + * + * Temporary data is removed once the extension version is changed, and thus + * objects should never write anything to temporary data that needs to survive + * an extension update. + * + * Persistent data is retained across extension versions and browser restarts; + * it can be removed when the user clears local storage from their profile. Do + * not store any sensitive or per-route data persistently. + */ + +goog.provide('mr.PersistentData'); +goog.provide('mr.PersistentDataManager'); +goog.require('mr.Logger'); + + + +/** + * @interface + */ +mr.PersistentData = class {}; + + +/** + * Alias for localStorage to deal with broken Storage interface. + * @const @private {!Object} + */ +mr.PersistentData.storageObj_ = /** @type {!Object} */ (window.localStorage); + + +/** + * Get the unique name of the object instance that has data to be saved. + * The name is used to isolate data from different objects. + * @return {string} + */ +mr.PersistentData.prototype.getStorageKey; + + +/** + * Invoked by persistent data manager when an object registers itself with the + * manager. The object should load its saved data in this method. + */ +mr.PersistentData.prototype.loadSavedData; + + +/** + * Implements this method to cleanup and return data that needs to be saved + * before the event page is shut down. The method should normally return a one- + * or two-element array of temporary and optional persistent data to save. + * + * Temporary data is cleared on browser restart or extension version change. + * Persistent data is retained until local storage is cleared by the browser + * profile. + * + * Any Objects must be serializable with JSON.stringify. + * + * The implementation of getData should not make any asynchronous + * calls; otherwise, there is no guarantee that asynchronous calls can finish. + * + * @return {!Array<!Object>} A one- or two-element array of data that needs to + * be saved. The first element is temporary data and the second element is + * persistent data. Return an empty array if there is no data to save. If + * only persistent data needs to be persisted, pass in undefined for the + * first element in the two-element array. + */ +mr.PersistentData.prototype.getData; + + +/** + * The total number of characters that can be stored in localStorage, + * approximately. + * @const {number} + */ +mr.PersistentDataManager.QUOTA_CHARS = 5200000; + + +/** + * The total number of characters used by persistent data. Note that writes that + * access localStorage directly may not be counted here. + + * @private {number} + */ +mr.PersistentDataManager.charsUsed_ = 0; + + +/** + * @param {!mr.PersistentData} obj The object that may have temporary data. + * @return {T} The data saved before. Null if no data is saved. + * @template T + */ +mr.PersistentDataManager.getTemporaryData = function(obj) { + const data = window.localStorage.getItem( + mr.PersistentDataManager.getStorageKey_(obj, false)); + return data ? JSON.parse(data) : null; +}; + + +/** + * @param {!mr.PersistentData} obj The object that may have peristent data. + * @return {T} The data saved before. Null if no data is saved. + * @template T + */ +mr.PersistentDataManager.getPersistentData = function(obj) { + const data = window.localStorage.getItem( + mr.PersistentDataManager.getStorageKey_(obj, true)); + return data ? JSON.parse(data) : null; +}; + + +/** + * Registers an object so that it gets informed about onSuspend events. + * + * @param {!mr.PersistentData} obj An object that has data to save. + */ +mr.PersistentDataManager.register = function(obj) { + if (mr.PersistentDataManager.dataInstances_.has(obj.getStorageKey())) { + throw Error('Duplicate instance name ' + obj.getStorageKey()); + } + mr.PersistentDataManager.dataInstances_.set(obj.getStorageKey(), obj); + obj.loadSavedData(); +}; + + +/** + * Un-Registers an object from being informed of onSuspend/Resume events. + * + * @param {!mr.PersistentData} obj An object to remove from tracking. + */ +mr.PersistentDataManager.unregister = function(obj) { + mr.PersistentDataManager.dataInstances_.delete(obj.getStorageKey()); +}; + + +/** + * @param {string} mrInstanceId The media router instance ID, which stays the + * same till Chrome restarts. + */ +mr.PersistentDataManager.initialize = function(mrInstanceId) { + let otherChars = 0; + for (let key of Object.keys(mr.PersistentData.storageObj_)) { + const itemSize = key.length + window.localStorage.getItem(key).length; + if (key.startsWith(mr.PersistentDataManager.KEY_PREFIX_)) { + mr.PersistentDataManager.charsUsed_ += itemSize; + } else { + otherChars += itemSize; + } + } + mr.PersistentDataManager.mrInstanceId_ = mrInstanceId; + if (mr.PersistentDataManager.isVersionChanged_() || + mr.PersistentDataManager.isChromeReloaded(mrInstanceId)) { + mr.PersistentDataManager.removeTemporary_(); + } + mr.PersistentDataManager.logger_.info( + 'initialize: ' + mr.PersistentDataManager.charsUsed_ + ' chars used, ' + + otherChars + ' other chars'); + chrome.runtime.onSuspend.addListener(mr.PersistentDataManager.onSuspend_); +}; + + +/** + * @private {?string} + */ +mr.PersistentDataManager.mrInstanceId_ = null; + + +/** + * @const {mr.Logger} + * @private + */ +mr.PersistentDataManager.logger_ = + mr.Logger.getInstance('mr.PersistentDataManager'); + + +/** + * A map from object's instance name to the object. + * @private @const {!Map<string, !mr.PersistentData>} + */ +mr.PersistentDataManager.dataInstances_ = new Map(); + + +/** @private @const {string} */ +mr.PersistentDataManager.KEY_PREFIX_ = 'mr.'; + + +/** + * @param {!mr.PersistentData} obj + * @param {boolean} persistent + * @return {string} + * @private + */ +mr.PersistentDataManager.getStorageKey_ = function(obj, persistent) { + return mr.PersistentDataManager.KEY_PREFIX_ + + (persistent ? 'persistent.' : 'temp.') + obj.getStorageKey(); +}; + + +/** + * Checks if the extension version has changed. + * @return {boolean} True if current extension has a different version as + * the saved version. + * @private + */ +mr.PersistentDataManager.isVersionChanged_ = function() { + return !!window.localStorage.getItem('version') && + window.localStorage.getItem('version') !== + chrome.runtime.getManifest().version; +}; + + +/** + * Checks if the chrome has been reloaded since the last time the extension is + * loaded. + * @param {string} mrInstanceId The media router instance ID, which stays the + * same till Chrome restarts. + * @return {boolean} True if Chrome was reloaded. + */ +mr.PersistentDataManager.isChromeReloaded = function(mrInstanceId) { + return !!window.localStorage.getItem('mrInstanceId') && + window.localStorage.getItem('mrInstanceId') !== mrInstanceId; +}; + + +/** + * Handles onSuspend event. + * @private + */ +mr.PersistentDataManager.onSuspend_ = function() { + mr.PersistentDataManager.logger_.info('onSuspend'); + + mr.PersistentDataManager.write( + 'version', chrome.runtime.getManifest().version); + if (mr.PersistentDataManager.mrInstanceId_) { + mr.PersistentDataManager.write( + 'mrInstanceId', mr.PersistentDataManager.mrInstanceId_); + } + const logManager = mr.PersistentDataManager.dataInstances_.get('LogManager'); + for (const [key, obj] of mr.PersistentDataManager.dataInstances_) { + if (obj != logManager) { + mr.PersistentDataManager.saveData( + /** @type {!mr.PersistentData} */ (obj)); + } + } + // Save the data for LogManager last, so that we save the logs generated + // during saveData() calls. + if (logManager) { + mr.PersistentDataManager.saveData(logManager); + } +}; + + +/** + * Save PersistentData object to local storage. + * @param {!mr.PersistentData} obj + */ +mr.PersistentDataManager.saveData = function(obj) { + try { + const data = obj.getData(); + if (data && data[0] != undefined) { + mr.PersistentDataManager.write( + mr.PersistentDataManager.getStorageKey_(obj, false), + JSON.stringify(data[0])); + } + if (data && data[1] != undefined) { + mr.PersistentDataManager.write( + mr.PersistentDataManager.getStorageKey_(obj, true), + JSON.stringify(data[1])); + } + } catch (e) { + mr.PersistentDataManager.logger_.error( + `Error while saving data for ${obj.getStorageKey()}: ${e.message}`); + } +}; + + +/** + * Writes value to localStorage under key. If the value is too large to fit + * into the remaining localStorage quota, temporary data is first removed. If + * the value still won't fit, an exception is thrown. + + * @param {string} key The localStorage key. + * @param {string} value The value to write. + */ +mr.PersistentDataManager.write = function(key, value) { + const dm = mr.PersistentDataManager; + let sizeDelta = 0; + const currentValue = window.localStorage.getItem(key); + if (currentValue != null) { + sizeDelta = value.length - currentValue.length; + } else { + sizeDelta = key.length + value.length; + } + + if (dm.charsUsed_ + sizeDelta > dm.QUOTA_CHARS) { + mr.PersistentDataManager.logger_.warning( + 'Unable to write ' + sizeDelta + ' chars'); + dm.removeTemporary_(); + } + + if (dm.charsUsed_ + sizeDelta > dm.QUOTA_CHARS) { + dm.logger_.error( + 'Unable to write ' + sizeDelta + ' chars after clearing temporary'); + throw Error( + `Setting the value of '${key}' would exceed the quota, ` + + 'according to accounting.'); + } + + try { + window.localStorage.setItem(key, value); + } catch (error) { + throw Error( + `Setting the value of '${key}' would exceed the quota, ` + + 'according to the browser.'); + } + // Adjusting dm.charsUsed_ only after the call to setItem() has succeeded. + dm.charsUsed_ += sizeDelta; +}; + + +/** + * Writes a Blob to localStorage under the given key, making space-efficient use + * of localStorage by encoding two of the Blob's bytes into each DOMString + * character. Use readBlob() to read the value back. + * @param {string} key The localStorage key. + * @param {!Blob} value The value to write. + * @return {!Promise<void>} Resolves once the Blob has been written; or rejects + * if it won't fit. + */ +mr.PersistentDataManager.writeBlob = function(key, value) { + // The byte size needs to be a multiple of two, since each string character + // code is a 16-bit unsigned integer. Thus, append padding byte(s) to the end + // of the Blob. These will also be used to indicate whether the original Blob + // was of even or odd length when reading back later. + if (value.size % 2 == 0) { + value = new Blob([value, new Uint8Array([0, 0])]); + } else { + value = new Blob([value, new Uint8Array([1])]); + } + + return new Promise((resolve, reject) => { + // Use FileReader to gain access to the Blob content via an ArrayBuffer. + const reader = new FileReader(); + reader.onloadend = () => { + if (reader.error) { + reject(reader.error); + return; + } + + try { + const buffer = /** @type {!ArrayBuffer} */ (reader.result); + + // Convert the buffer bytes into a string, storing two bytes in each of + // the string's characters for space efficiency. This is done in batches + // to avoid smashing the call stack when calling String.fromCharCode(). + const batchSize = 8192; + const pieces = []; + for (let pos = 0, end = buffer.byteLength; pos < end; + pos += batchSize) { + // Note: The byteLength will always be an even number since all input + // values to the following expression must be even numbers: + const byteLengthOfChunk = Math.min(end - pos, batchSize); + pieces.push(String.fromCharCode.apply( + null, new Uint16Array(buffer, pos, byteLengthOfChunk / 2))); + } + + // Finally, join the pieces into a single string and attempt to store + // the string using the quota management heuristics in write(). + mr.PersistentDataManager.write(key, pieces.join('')); + + resolve(); + } catch (error) { + reject(error); + } + }; + reader.readAsArrayBuffer(value); + }); +}; + + +/** + * Reads a Blob from localStorage under the given key. Returns null if it does + * not exist. + * @param {string} key The localStorage key. + * @param {Object=} blobOptions The options for the reconstituted Blob (e.g., + * {'type': 'application/gzip'}). + * @return {?Blob} + */ +mr.PersistentDataManager.readBlob = function(key, blobOptions) { + const asString = window.localStorage.getItem(key); + if (asString == null || asString.length < 1) { + return null; + } + const charCodes = new Uint16Array(asString.length); + for (let i = 0; i < asString.length; ++i) { + charCodes[i] = asString.charCodeAt(i); + } + // Determine, from the last byte value, whether the original Blob was of even + // or odd length (see writeBlob()). Create a Blob from a view of all but the + // padding byte(s) at the end of the buffer. + const buffer = charCodes.buffer; + const flagByte = (new Uint8Array(buffer, buffer.byteLength - 1, 1))[0]; + const viewOfPayload = + new Uint8Array(buffer, 0, buffer.byteLength - ((flagByte == 0) ? 2 : 1)); + return new Blob([viewOfPayload], blobOptions); +}; + + +/** + * Removes temporary data. + * @private + */ +mr.PersistentDataManager.removeTemporary_ = function() { + for (let key of Object.keys(mr.PersistentData.storageObj_)) { + if (key.startsWith(mr.PersistentDataManager.KEY_PREFIX_ + 'temp.')) { + mr.PersistentDataManager.charsUsed_ -= + (key.length + window.localStorage.getItem(key).length); + delete window.localStorage[key]; + } + } + mr.PersistentDataManager.logger_.info( + 'removeTemporary_: ' + mr.PersistentDataManager.charsUsed_ + + ' chars used'); +}; + + +/** + * Removes all data. + * @private + */ +mr.PersistentDataManager.removeAll_ = function() { + for (let key of Object.keys(mr.PersistentData.storageObj_)) { + if (key.startsWith(mr.PersistentDataManager.KEY_PREFIX_)) + window.localStorage.removeItem(key); + } + mr.PersistentDataManager.charsUsed_ = 0; +}; + + +/** + * Clears internal state and remove all saved data. + * Utility method for unit test. + + */ +mr.PersistentDataManager.clear = function() { + mr.PersistentDataManager.removeAll_(); + mr.PersistentDataManager.dataInstances_.clear(); +}; + + +/** + * Simulates suspend. + * Utility method for unit test. + + */ +mr.PersistentDataManager.suspendForTest = function() { + mr.PersistentDataManager.onSuspend_(); + mr.PersistentDataManager.dataInstances_.clear(); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/persistent_data_test.js b/chromium/chrome/browser/resources/media_router/extension/src/persistent_data_test.js new file mode 100644 index 00000000000..6708e9fdb4f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/persistent_data_test.js @@ -0,0 +1,361 @@ +// 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. + +goog.setTestOnly('persistent_data_test'); + +goog.require('mr.PersistentData'); +goog.require('mr.PersistentDataManager'); +goog.require('mr.UnitTestUtils'); + + +/** + * @implements {mr.PersistentData} + * @private + */ +DummyData_ = class { + /** + * @param {string} id + */ + constructor(id) { + /** @private {string} */ + this.id_ = id; + } + + /** + * @override + */ + getData() { + return []; + } + + /** + * @override + */ + getStorageKey() { + return 'dummy-data' + this.id_; + } + + /** + * @override + */ + loadSavedData() {} +}; + + +describe('Tests PersistentDataManager', () => { + let dataInstance1; + let dataInstance2; + let onSuspendListener; + let version; + let mrInstanceId; + const originalQuota = mr.PersistentDataManager.QUOTA_CHARS; + + beforeEach(() => { + window.localStorage.clear(); + mr.PersistentDataManager.charsUsed_ = 0; + dataInstance1 = new DummyData_('1'); + dataInstance2 = new DummyData_('2'); + version = '1.0'; + mrInstanceId = '123'; + mr.UnitTestUtils.mockChromeApi(); + chrome.runtime.onSuspend = { + addListener: l => { + onSuspendListener = l; + } + }; + chrome.runtime.getManifest = () => ({'version': version}); + mr.PersistentDataManager.initialize(mrInstanceId); + dataInstance1.loadSavedData = jasmine.createSpy('loadSavedData'); + dataInstance2.loadSavedData = jasmine.createSpy('loadSavedData'); + mr.PersistentDataManager.register(dataInstance1); + mr.PersistentDataManager.register(dataInstance2); + expect(dataInstance1.loadSavedData).toHaveBeenCalled(); + expect(dataInstance2.loadSavedData).toHaveBeenCalled(); + }); + + afterEach(() => { + mr.PersistentDataManager.clear(); + mr.PersistentDataManager.QUOTA_CHARS = originalQuota; + mr.UnitTestUtils.restoreChromeApi(); + }); + + it('returns null with no saved data', () => { + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toBe(null); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance2)).toBe(null); + }); + + describe('handles onSuspend', () => { + it('with one instance with data', () => { + dataInstance1.getData = () => [{'d': 1}, {'e': 1}]; + dataInstance2.getData = () => null; + onSuspendListener(); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toEqual({ + 'd': 1 + }); + expect(mr.PersistentDataManager.getPersistentData(dataInstance1)) + .toEqual({'e': 1}); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance2)) + .toBe(null); + expect(mr.PersistentDataManager.charsUsed_).toBe(83); + }); + + it('with two instances with data', () => { + dataInstance1.getData = () => [{'d': 1}, {'e': 1}]; + dataInstance2.getData = () => [{'d': 2}, {'e': 2}]; + onSuspendListener(); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toEqual({ + 'd': 1 + }); + expect(mr.PersistentDataManager.getPersistentData(dataInstance1)) + .toEqual({'e': 1}); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance2)).toEqual({ + 'd': 2 + }); + expect(mr.PersistentDataManager.getPersistentData(dataInstance2)) + .toEqual({'e': 2}); + expect(mr.PersistentDataManager.charsUsed_).toBe(141); + }); + + it('with no instance with data', () => { + dataInstance1.getData = () => null; + dataInstance2.getData = () => null; + onSuspendListener(); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)) + .toBe(null); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance2)) + .toBe(null); + expect(mr.PersistentDataManager.charsUsed_).toBe(25); + }); + + it('with an instance with a circular dependency', () => { + const data1 = {}; + data1.data = data1; + dataInstance1.getData = () => [{'d': data1}, {'e': data1}]; + dataInstance2.getData = () => [{'d': 2}, {'e': 2}]; + onSuspendListener(); + + // If there is a circular dependency of objects, that data cannot be + // converted into JSON or saved. However other data should still be saved. + expect(mr.PersistentDataManager.getTemporaryData(dataInstance2)).toEqual({ + 'd': 2 + }); + expect(mr.PersistentDataManager.getPersistentData(dataInstance2)) + .toEqual({'e': 2}); + }); + }); + + it('Test saveData', () => { + dataInstance1.getData = () => [{'d': 1}, {'e': 1}]; + dataInstance2.getData = () => [{'d': 2}, {'e': 2}]; + const dataInstance3 = new DummyData_('3'); + dataInstance3.getData = () => [false, {'e': 3}]; + const dataInstance4 = new DummyData_('4'); + dataInstance4.getData = () => [undefined, 0]; + + // Note that dataInstance2 is not saved. + mr.PersistentDataManager.saveData(dataInstance1); + mr.PersistentDataManager.saveData(dataInstance3); + mr.PersistentDataManager.saveData(dataInstance4); + + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toEqual({ + 'd': 1 + }); + expect(mr.PersistentDataManager.getPersistentData(dataInstance1)).toEqual({ + 'e': 1 + }); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance2)).toBeNull(); + expect(mr.PersistentDataManager.getPersistentData(dataInstance2)) + .toBeNull(); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance3)) + .toEqual(false); + expect(mr.PersistentDataManager.getPersistentData(dataInstance3)).toEqual({ + 'e': 3 + }); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance4)).toBeNull(); + expect(mr.PersistentDataManager.getPersistentData(dataInstance4)) + .toEqual(0); + }); + + it('Test unregister', () => { + dataInstance1.getData = () => [{'d': 1}, {'e': 1}]; + dataInstance2.getData = () => [{'d': 2}, {'e': 2}]; + const dataInstance3 = new DummyData_('3'); + dataInstance3.getData = () => [{'d': 3}, {'e': 3}]; + mr.PersistentDataManager.register(dataInstance3); + onSuspendListener(); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance3)).toEqual({ + 'd': 3 + }); + mr.PersistentDataManager.unregister(dataInstance3); + // Get data should not be called again. + dataInstance3.getData = () => { + fail(); + }; + onSuspendListener(); + }); + + it('handles version change', () => { + dataInstance1.getData = () => [{'d': 1}, {'e': 1}]; + onSuspendListener(); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toEqual({ + 'd': 1 + }); + expect(mr.PersistentDataManager.getPersistentData(dataInstance1)).toEqual({ + 'e': 1 + }); + version = '1.1'; + expect(mr.PersistentDataManager.isChromeReloaded(mrInstanceId)).toBe(false); + mr.PersistentDataManager.initialize(mrInstanceId); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toBe(null); + expect(mr.PersistentDataManager.getPersistentData(dataInstance1)).toEqual({ + 'e': 1 + }); + }); + + it('handles mrInstanceId change', () => { + dataInstance1.getData = () => [{'d': 1}, {'e': 1}]; + onSuspendListener(); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toEqual({ + 'd': 1 + }); + expect(mr.PersistentDataManager.getPersistentData(dataInstance1)).toEqual({ + 'e': 1 + }); + mrInstanceId = '321'; + expect(mr.PersistentDataManager.isChromeReloaded(mrInstanceId)).toBe(true); + mr.PersistentDataManager.initialize(mrInstanceId); + expect(mr.PersistentDataManager.getTemporaryData(dataInstance1)).toBe(null); + expect(mr.PersistentDataManager.getPersistentData(dataInstance1)).toEqual({ + 'e': 1 + }); + }); + + describe('allows writes', () => { + it('of small values', () => { + mr.PersistentDataManager.write('mr.temp.Buckaroo', 'Bonzai'); + expect(window.localStorage.getItem('mr.temp.Buckaroo')).toBe('Bonzai'); + expect(mr.PersistentDataManager.charsUsed_).toBe(22); + }); + + it('of large values over quota', () => { + mr.PersistentDataManager.QUOTA_CHARS = 100; + [1, 2, 3, 4].forEach(index => { + mr.PersistentDataManager.write( + 'mr.temp.' + index, 'Only the dead have seen the end of war.'); + }); + expect(mr.PersistentDataManager.charsUsed_).toBe(96); + // Normally this would go over QUOTA_CHARS, but clearing temporary + // values allows it to succeed. + mr.PersistentDataManager.write( + 'mr.persistent.5', 'Courage is knowing what not to fear.'); + expect(mr.PersistentDataManager.charsUsed_).toBe(51); + [1, 2, 3, 4].forEach(index => { + expect(window.localStorage.getItem('mr.temp.' + index)).toBeNull(); + }); + expect(window.localStorage.getItem('mr.persistent.5')) + .toBe('Courage is knowing what not to fear.'); + }); + }); + + describe('stores and reads Blobs', () => { + const dm = mr.PersistentDataManager; + const testKey = 'test'; + + const allUint16Values = []; + for (let i = 0; i < (1 << 16); ++i) { + allUint16Values.push(i); + } + + // Takes an array of byte values, creates a Blob, stores and retrieves it + // back, and then maps the bytes of the Blob back to an array of byte + // values. + function writeThenReadByteValues(values) { + return new Promise((resolve, reject) => { + dm.writeBlob(testKey, new Blob([new Uint8Array(values)])).then(() => { + const reader = new FileReader(); + reader.onloadend = () => { + if (reader.error) { + reject(reader.error); + } else { + resolve(Array.from(new Uint8Array(reader.result))); + } + }; + reader.readAsArrayBuffer(dm.readBlob(testKey)); + }, reject); + }); + } + + // Same as writeThenReadByteValues(), except operate on an array of uint16. + function writeThenReadShortValues(values) { + return new Promise((resolve, reject) => { + dm.writeBlob(testKey, new Blob([new Uint16Array(values)])).then(() => { + const reader = new FileReader(); + reader.onloadend = () => { + if (reader.error) { + reject(reader.error); + } else { + resolve(Array.from(new Uint16Array(reader.result))); + } + }; + reader.readAsArrayBuffer(dm.readBlob(testKey)); + }, reject); + }); + } + + it('of zero size', done => { + writeThenReadByteValues([]).then(values => { + expect(values).toEqual([]); + done(); + }, fail); + }); + + it('of even size', done => { + writeThenReadByteValues([42, 1]).then(values => { + expect(values).toEqual([42, 1]); + done(); + }, fail); + }); + + it('of odd size', done => { + writeThenReadByteValues([42, 1, 0]).then(values => { + expect(values).toEqual([42, 1, 0]); + done(); + }, fail); + }); + + it('containing all possible uint16 values', done => { + const original = allUint16Values.concat(allUint16Values.reverse()); + writeThenReadShortValues(original).then(values => { + expect(values).toEqual(original); + done(); + }, fail); + }); + + it('that are just within quota', done => { + // Note on quota space needed: The string length of the key plus value + // must be available. A blob of 7 uint16 values will have a byte size of + // 14. The impl will add two padding bytes to the end, making the total 16 + // bytes. Thus, 16/2 = 8 chars of quota must be available for the value. + // In total, 4 + 8 (key + value) chars must be available. + mr.PersistentDataManager.QUOTA_CHARS = + mr.PersistentDataManager.charsUsed_ + testKey.length + 8; + const original = allUint16Values.slice(0, 7); + writeThenReadShortValues(original).then(values => { + expect(values).toEqual(original); + done(); + }, fail); + }); + + it('that exceed quota', done => { + // See note above on how quota accounting works. + mr.PersistentDataManager.QUOTA_CHARS = + mr.PersistentDataManager.charsUsed_ + testKey.length + 8; + const original = allUint16Values.slice(0, 8); + writeThenReadShortValues(original).then(fail, error => { + expect(dm.readBlob(testKey)).toBeNull(); + done(); + }); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/presentation.js b/chromium/chrome/browser/resources/media_router/extension/src/presentation.js new file mode 100644 index 00000000000..07ed021276b --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/presentation.js @@ -0,0 +1,191 @@ +// 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. + +/** + * @fileoverview Presentation API. + * @externs + * @see http://w3c.github.io/presentation-api + * @see https://code.google.com/p/chromium/codesearch#chromium/src/third_party/WebKit/Source/modules/presentation/ + */ + + + +/** + * @interface + * @see http://w3c.github.io/presentation-api/#idl-def-presentationconnection + */ +function PresentationConnection() {} + + +/** + * @type {string} + * @see http://w3c.github.io/presentation-api/#dom-presentationconnection-id + */ +PresentationConnection.prototype.id; + + +/** + * @type {string} + * @see http://w3c.github.io/presentation-api/#dom-presentationconnection-url + */ +PresentationConnection.prototype.url; + + +/** + * @type {string} + * @see http://w3c.github.io/presentation-api/#dom-presentationconnection-state + */ +PresentationConnection.prototype.state; + + +/** + * @type {?function(!MessageEvent)} + * @see http://w3c.github.io/presentation-api/#dom-presentationconnection-onmessage + */ +PresentationConnection.prototype.onmessage; + + +/** + * @type {?function(!PresentationConnectionCloseEvent)} + * @see http://w3c.github.io/presentation-api/#dom-presentationconnection-onclose + */ +PresentationConnection.prototype.onclose; + + +/** + * @type {?function()} + * @see https://www.w3.org/TR/presentation-api/#dom-presentationconnection-onterminate + */ +PresentationConnection.prototype.onterminate; + + +/** + * @param {string} message + * @see http://w3c.github.io/presentation-api/#dom-presentationconnection-send + */ +PresentationConnection.prototype.send = function(message) {}; + +/** + * @see https://w3c.github.io/presentation-api/#dom-presentationconnection-terminate + */ +PresentationConnection.prototype.terminate = function() {}; + +/** + * @see http://w3c.github.io/presentation-api/#dom-presentationconnection-close + */ +PresentationConnection.prototype.close = function() {}; + + + +/** + * @constructor + * @extends {Event} + */ +function PresentationConnectionCloseEvent() {} + + +/** @type {string} */ +PresentationConnectionCloseEvent.prototype.reason; + + +/** @type {string} */ +PresentationConnectionCloseEvent.prototype.message; + + + +/** + * @constructor + * @extends {Event} + * @see http://w3c.github.io/presentation-api/#presentationavailability + */ +function PresentationAvailability() {} + + +/** @type {boolean} */ +PresentationAvailability.prototype.value; + + +/** @type {?function()} */ +PresentationAvailability.prototype.onchange; + + + +/** + * @constructor + * @extends {Event} + * @see http://w3c.github.io/presentation-api/#availablechangeevent + */ +function AvailableChangeEvent() {} + + +/** + * @type {boolean} + */ +AvailableChangeEvent.prototype.available; + + + +/** + * @constructor + * @extends {Event} + */ +function PresentationConnectionAvailableEvent() {} + + +/** + * @type {!PresentationConnection} + */ +PresentationConnectionAvailableEvent.prototype.connection; + + + +/** + * @param {string|!Array<string>} url + * @constructor + */ +function PresentationRequest(url) {} + + +/** + * @return {!Promise<!PresentationConnection>} + */ +PresentationRequest.prototype.start = function() {}; + + +/** + * @param {string} presentationId + * @return {!Promise<!PresentationConnection>} + */ +PresentationRequest.prototype.reconnect = function(presentationId) {}; + + +/** + * @return {!Promise<!PresentationAvailability>} + */ +PresentationRequest.prototype.getAvailability = function() {}; + + +/** + * @type {?function(!PresentationConnectionAvailableEvent)} + */ +PresentationRequest.prototype.onconnectionavailable; + + + +/** + * @interface + */ +function Presentation() {} + + +/** + * @type {PresentationRequest} + */ +Presentation.prototype.defaultRequest; + + +/** + * @type {!Presentation} + */ +Navigator.prototype.presentation; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/presentation_services/cloud_webrtc/webrtc_presentation_session.js b/chromium/chrome/browser/resources/media_router/extension/src/presentation_services/cloud_webrtc/webrtc_presentation_session.js new file mode 100644 index 00000000000..907ac9b5aab --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/presentation_services/cloud_webrtc/webrtc_presentation_session.js @@ -0,0 +1,192 @@ +// 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. + +/** + * @fileoverview Implementation of PresentationSession that uses the WebRTC + * PeerConnection. + */ +goog.provide('mr.presentation.webrtc.CloudWebRtcSession'); + +goog.require('mr.MessagePortService'); +goog.require('mr.PromiseResolver'); +goog.require('mr.presentation.Session'); +goog.require('mr.webrtc.Message'); +goog.require('mr.webrtc.MessageType'); +goog.require('mr.webrtc.OfferMessageData'); +goog.require('mr.webrtc.PeerConnection'); + +goog.scope(function() { + + + + +/** + * Constructs a new WebRTC presentation session. + * @implements {mr.presentation.Session} + */ +mr.presentation.webrtc.CloudWebRtcSession = class { + /** + * @param {!mr.Route} route + * @param {!string} sourceUrn + */ + constructor(route, sourceUrn) { + /** @type {!mr.Route} */ + this.route = route; + + /** @type {!string} */ + this.sourceUrn = sourceUrn; + + /** @private {!mr.PromiseResolver<!mr.webrtc.PeerConnection>} */ + this.peerConnectionResolver_ = new mr.PromiseResolver(); + + /** @private {!Promise<!mr.webrtc.PeerConnection>} */ + this.peerConnection_ = this.peerConnectionResolver_.promise; + + /** @private {!mr.MessagePort} */ + this.messagePort_ = + mr.MessagePortService.getService().getInternalMessenger(route.id); + + /** @private {!mr.PromiseResolver<!mr.Route>} */ + this.startResolver_ = new mr.PromiseResolver(); + + /** @private {boolean} */ + this.started_ = false; + + this.setUpMessagePort_(); + this.setUpPeerConnection_(); + + // Send a request for TURN credentials, then expect a response message with + // type TURN_CREDENTIALS. + this.sendMessageToMrp_( + new mr.webrtc.Message(mr.webrtc.MessageType.GET_TURN_CREDENTIALS)); + } + + /** + * @override + */ + start() { + return this.peerConnection_.then(pc => { + if (pc.isStarted()) { + return Promise.reject(Error('Presentation already started')); + } + + pc.start(); + return this.startResolver_.promise; + }); + } + + /** + * @override + */ + stop() { + this.started_ = false; + return this.peerConnection_.then(pc => { + pc.stop(); + this.peerConnection_ = + Promise.reject(Error('Peer connection has already been stopped')); + }); + } + + /** + * Sets up the message port to receive incoming messages. + * @private + */ + setUpMessagePort_() { + this.messagePort_.onMessage = message => { + if (!message.type) { + // Wrap message and send it along as a presentation message. + this.peerConnection_.then(pc => { + pc.sendDataChannelMessage({ + type: mr.webrtc.MessageType.PRESENTATION_CONNECTION_MESSAGE, + data: message + }); + }); + return; + } + switch (message.type) { + case mr.webrtc.MessageType.TURN_CREDENTIALS: + // Response to a GET_TURN_CREDENTIALS message. This causes the + // PeerConnection to be created. + this.peerConnectionResolver_.resolve(new mr.webrtc.PeerConnection( + this.route.id, + /** @type {!Array<!mr.webrtc.TurnCredential>} */ + (message.data['credentials']))); + break; + case mr.webrtc.MessageType.ANSWER: + this.peerConnection_.then(pc => { + pc.setRemoteDescription(message.data); + }); + break; + case mr.webrtc.MessageType.STOP: + this.startResolver_.reject('Stop signal received'); + this.stop(); + break; + default: + throw Error('Unknown message type: ' + message.type); + } + }; + } + + /** + * Sets up the peer connection (with callbacks). + * @private + */ + setUpPeerConnection_() { + this.peerConnection_.then(pc => { + // Pass the description up the MessagePort. + pc.setOnOfferDescriptionReady(description => { + const offerData = new mr.webrtc.OfferMessageData( + description, + /* opt_settings_ */ null, + /* opt_mediaConstraints */ null, this.sourceUrn, this.route.id); + const message = + new mr.webrtc.Message(mr.webrtc.MessageType.OFFER, offerData); + this.sendMessageToMrp_(message); + }); + // Pass along the data channel message up the MessagePort. + pc.setOnDataChannelMessage(message => { + // Check if message is a STOP message and calls stop() before sending it + // through the message port to MRP. + const webRtcMessage = mr.webrtc.Message.fromString(message); + if (webRtcMessage.type == mr.webrtc.MessageType.STOP) { + this.stop(); + } + this.sendMessageToMrp_(webRtcMessage); + }); + + // Send the connection success/closed/failed events up the MessagePort, + // and + // also resolve or reject the start() promise. + pc.setOnConnectionSuccess(event => { + this.started_ = true; + this.sendMessageToMrp_( + new mr.webrtc.Message(mr.webrtc.MessageType.SESSION_START_SUCCESS)); + this.startResolver_.resolve(this.route); + }); + pc.setOnConnectionClosed(event => { + this.sendMessageToMrp_( + new mr.webrtc.Message(mr.webrtc.MessageType.SESSION_END)); + }); + pc.setOnConnectionFailure(error => { + // If we haven't started yet, reject the start promise. + if (!this.started_) { + this.startResolver_.reject(error); + } + this.sendMessageToMrp_( + new mr.webrtc.Message(mr.webrtc.MessageType.SESSION_FAILURE)); + }); + }); + } + + /** + * Sends the provided message to the MRP via the message port. + * @param {!Object} message + * @private + */ + sendMessageToMrp_(message) { + this.messagePort_.sendMessage(message); + } +}; + +}); // goog.scope diff --git a/chromium/chrome/browser/resources/media_router/extension/src/presentation_services/presentation_session.js b/chromium/chrome/browser/resources/media_router/extension/src/presentation_services/presentation_session.js new file mode 100644 index 00000000000..02b2ad5253b --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/presentation_services/presentation_session.js @@ -0,0 +1,31 @@ +// 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. + +/** + * @fileoverview Interface to a presentation session. + */ + +goog.provide('mr.presentation.Session'); + + + +/** + * Creates a new PresentationSession. + * @record + */ +mr.presentation.Session = class { + /** + * Starts the presentation session. + * @return {!Promise<!mr.Route>} Fulfilled when the + * session has been created and transports have started. + */ + start() {} + /** + * Stops the presentation session. The underlying streams and transports are + * stopped and destroyed. + * + * @return {!Promise} Promise that resolves when the session is stopped. + */ + stop() {} +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/id_generator.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/id_generator.js new file mode 100644 index 00000000000..91096c736f8 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/id_generator.js @@ -0,0 +1,80 @@ +// 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. + +/** + * @fileoverview An ID generator that keeps its state across extension + * suspend/wakeup cycles. + * + + */ + +goog.provide('mr.IdGenerator'); + +goog.require('mr.PersistentData'); +goog.require('mr.PersistentDataManager'); + + +/** + * @implements {mr.PersistentData} + */ +mr.IdGenerator = class { + /** + * @param {!string} storageKey + */ + constructor(storageKey) { + /** @private @const {!string} */ + this.storageKey_ = storageKey; + + /** @private {!number} */ + this.nextId_ = Math.floor(Math.random() * 1e6) * 1000; + } + + /** + * Enables persistent + */ + enablePersistent() { + mr.PersistentDataManager.register(this); + } + + /** + * @return {!number} The next ID. 0 will never be returned. + */ + getNext() { + let nextId = this.nextId_++; + if (nextId == 0) { + // Skip 0, which is used by Cast receiver to + // indicate that the broadcast status message is not coming from a + // specific + // sender (it is an autonomous status change, not triggered by a command + // from any sender). Strange usage of 0 though; could be a null / optional + // field. + nextId = this.nextId_++; + } + return nextId; + } + + /** + * @override + */ + getStorageKey() { + return 'IdGenerator.' + this.storageKey_; + } + + /** + * @override + */ + getData() { + return [this.nextId_]; + } + + /** + * @override + */ + loadSavedData() { + const savedData = mr.PersistentDataManager.getTemporaryData(this); + if (savedData) { + this.nextId_ = savedData; + } + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/id_generator_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/id_generator_test.js new file mode 100644 index 00000000000..1e9e37a220d --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/id_generator_test.js @@ -0,0 +1,36 @@ +// 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. + +goog.require('mr.IdGenerator'); +goog.require('mr.PersistentDataManager'); + +describe('IdGenerator Tests', function() { + let generator; + + beforeEach(function() { + spyOn(Math, 'random').and.returnValue(0); + generator = new mr.IdGenerator('test'); + generator.enablePersistent(); + }); + + afterEach(function() { + mr.PersistentDataManager.clear(); + }); + + it('getNext', function() { + expect(generator.getNext()).toBe(1); + expect(generator.getNext()).toBe(2); + expect(generator.getNext()).toBe(3); + }); + + it('Persistent', function() { + expect(generator.getNext()).toBe(1); + expect(generator.getNext()).toBe(2); + mr.PersistentDataManager.suspendForTest(); + generator = new mr.IdGenerator('test'); + generator.loadSavedData(); + expect(generator.getNext()).toBe(3); + }); + +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/net_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/net_utils.js new file mode 100644 index 00000000000..fe52d1d5ce2 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/net_utils.js @@ -0,0 +1,98 @@ +// 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. + +/** + * @fileoverview Utilites for handling network data. + + */ + +goog.module('mr.NetUtils'); +goog.module.declareLegacyNamespace(); + + + +/** @const @private {!RegExp} */ +exports.IPV4_REGEXP_ = + new RegExp('^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$'); + +/** @const @private {!Array<number>} */ +exports.IPV4_PRIVATE_ADDRESS_MASKS_ = [ + // 10.0.0.0/8 + 4278190080, + // 172.16.0.0/12 + 4293918720, + // 192.168.0.0/16 + 4294901760 +]; + +/** @const @private {!Array<number>} */ +exports.IPV4_PRIVATE_ADDRESS_SUBNETS_ = [ + // 10.0.0.0/8 + 167772160, + // 172.16.0.0/12 + 2886729728, + // 192.168.0.0/16 + 3232235520 +]; + +/** + * Parses an IPv4 dotted-quad address into an array of four numbers, or + * returns null if the argument cannot be parsed. + * + * @param {string} ipAddress + * @return {?Array<number>} Array of four integers 0-255 or null. + */ +exports.parseIPv4Address = function(ipAddress) { + const matches = ipAddress.match(exports.IPV4_REGEXP_); + if (!matches || matches.length != 5) return null; + const result = []; + for (let i = 0; i < 4; i++) { + result[i] = Number.parseInt(matches[i + 1], 10); + if (result[i] < 0 || result[i] > 255) return null; + } + return result; +}; + +/** + * Returns true if ipAddress can be parsed into a valid IPv4 private network + * address. + * + * @param {string} ipAddress + * @return {boolean} True if ipAddress is a valid IPv4 private network address. + */ +exports.isPrivateIPv4Address = function(ipAddress) { + const parsedAddress = exports.parseIPv4Address(ipAddress); + if (!parsedAddress) return false; + // >>> 0 converts a signed integer to unsigned, so bitwise operations are + // sensible. + const addressValue = (parsedAddress[0] << 24 | parsedAddress[1] << 16 | + parsedAddress[2] << 8 | parsedAddress[0]) >>> + 0; + for (let i = 0; i < 3; i++) { + if ((addressValue & exports.IPV4_PRIVATE_ADDRESS_MASKS_[i]) >>> 0 == + exports.IPV4_PRIVATE_ADDRESS_SUBNETS_[i]) + return true; + } + return false; +}; + +/** + * @param {string} url A url. + * @return {!HTMLAnchorElement} The result of parsing url. + */ +exports.parseUrl = function(url) { + const a = document.createElement('a'); + a.href = url; + return /** @type {!HTMLAnchorElement} */ (a); +}; + +/** + * Non-exhaustive list of HTTP status codes. Add new codes as needed here. + * @enum {number} + */ +const HttpStatus = { + NOT_FOUND: 404, +}; + +exports.HttpStatus = HttpStatus; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/net_utils_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/net_utils_test.js new file mode 100644 index 00000000000..4143201990b --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/net_utils_test.js @@ -0,0 +1,61 @@ +// 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. + +/** + * @fileoverview Unit tests for mr.NetUtils. + + */ + +goog.module('mr.NetUtilsTest'); +goog.setTestOnly('mr.NetUtilsTest'); + +const n = goog.require('mr.NetUtils'); + +describe('mr.NetUtils', () => { + + it('parses a valid IPv4 address', () => { + expect(n.parseIPv4Address('128.164.100.104')).toEqual([128, 164, 100, 104]); + expect(n.parseIPv4Address('0.0.0.0')).toEqual([0, 0, 0, 0]); + expect(n.parseIPv4Address('255.255.255.255')).toEqual([255, 255, 255, 255]); + }); + + it('does not parse an invalid IPv4 address', () => { + expect(n.parseIPv4Address('')).toBeNull(); + expect(n.parseIPv4Address('deadbeef')).toBeNull(); + expect(n.parseIPv4Address('128.164.100')).toBeNull(); + expect(n.parseIPv4Address('128.164.100.104.333')).toBeNull(); + expect(n.parseIPv4Address('256.164.100.104')).toBeNull(); + expect(n.parseIPv4Address('-1.164.100.104')).toBeNull(); + }); + + it('validates an IPv4 private network address', () => { + expect(n.isPrivateIPv4Address('10.0.0.0')).toBe(true); + expect(n.isPrivateIPv4Address('10.255.255.255')).toBe(true); + expect(n.isPrivateIPv4Address('172.16.0.0')).toBe(true); + expect(n.isPrivateIPv4Address('172.31.255.255')).toBe(true); + expect(n.isPrivateIPv4Address('192.168.0.0')).toBe(true); + expect(n.isPrivateIPv4Address('192.168.255.255')).toBe(true); + }); + + it('does not validate an IPv4 public network address', () => { + expect(n.isPrivateIPv4Address('9.255.255.255')).toBe(false); + expect(n.isPrivateIPv4Address('11.0.0.0')).toBe(false); + expect(n.isPrivateIPv4Address('172.15.255.255')).toBe(false); + expect(n.isPrivateIPv4Address('172.32.0.0')).toBe(false); + expect(n.isPrivateIPv4Address('193.167.255.255')).toBe(false); + expect(n.isPrivateIPv4Address('193.169.0.0')).toBe(false); + }); + + it('parses a URL', () => { + const url = + n.parseUrl('https://www.example.com:8080/a/path?a_query#a_fragment'); + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('www.example.com'); + expect(url.port).toBe('8080'); + expect(url.pathname).toBe('/a/path'); + expect(url.search).toBe('?a_query'); + expect(url.hash).toBe('#a_fragment'); + expect(url.host).toBe('www.example.com:8080'); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/retry.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/retry.js new file mode 100644 index 00000000000..326dbfa1d54 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/retry.js @@ -0,0 +1,198 @@ +// 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. + +/** + * @fileoverview + * A class for attempting some unreliable operation until it succeeds. Sort of + * an asynchronous while loop. + * + * (new mr.Retry(attempt, 500, 10)).start().then(onSuccess, onFailure); + * + * The next attempt is driven by the failure of current attempt. Thus, there is + * at most one attempt invocation at any time. + */ + +goog.provide('mr.Retry'); + +goog.require('mr.Assertions'); +goog.require('mr.PromiseResolver'); + + +/** + * An object that attempts some operation until it succeeds. + * + * @template R + */ +mr.Retry = class { + /** + * @param {function():!Promise<R>} onAttempt An + * idempotent function to call repeatedly until it succeeds. + * @param {number} retryDelay The number of milliseconds to wait between the + * start of one attempt and the start of the next. It must be positive. + * More precisely, this is the amount of time to wait between the failure + * of one call to onAttempt(). + * @param {number} maxAttempts The maximum number of attempts. + * It must be positive. + */ + constructor(onAttempt, retryDelay, maxAttempts) { + /** + * @private {!function():!Promise<R>} + */ + this.onAttempt_ = onAttempt; + + /** + * @private {number} + */ + this.retryDelay_ = retryDelay > 0 ? retryDelay : 10; + + /** + * @private {number} + */ + this.maxAttempts_ = maxAttempts > 0 ? maxAttempts : 1; + + /** + * @private {number} + */ + this.maxRetryDelay_ = 0; + + /** + * @private {number} + */ + this.backoffFactor_ = 1; + + /** + * The number of times `onAttempt_` has been called. + * @private {number} + */ + this.numAttemptsStarted_ = 0; + + /** + * @private {boolean} + */ + this.isFinished_ = false; + + /** + * The ID of the most recently created timer. + * @private {?number} + */ + this.timerId_ = null; + + /** @private {mr.PromiseResolver} */ + this.resolver_ = null; + } + + /** + * Starts running this object. + * + * This method starts an asynchronous process that repeatedly calls + * `onAttempt`. + * + * For each attempt, `onAttempt` is called. When `onAttempt` + * resolves, the returned promise is resolved with the same result. + * The returned promise rejects if `abort` is called on this object, + * or the number of attempts specified by `setMaxAttempts` is reached. + * + * @return {!Promise<R>} + * @template R + */ + start() { + if (this.resolver_ != null) { + return Promise.reject(Error('Cannot call Retry.start more than once.')); + } + this.resolver_ = new mr.PromiseResolver(); + this.retryOnce_(); + return this.resolver_.promise; + } + + /** + * Makes the next call to `onAttempt_`. + * @private + */ + retryOnce_() { + this.timerId_ = null; + if (this.isFinished_) { + // The abort method has been called, don't start a new attempt. + return; + } + + this.numAttemptsStarted_++; + this.onAttempt_().then( + result => { + this.cleanup_(); + this.resolver_.resolve(result); + }, + error => { + if (this.numAttemptsStarted_ >= this.maxAttempts_) { + // Maximum number of attempts has been reached, do not try again. + this.cleanup_(); + this.resolver_.reject(Error('Max attempts reached')); + } else { + this.timerId_ = + setTimeout(this.retryOnce_.bind(this), this.retryDelay_); + this.updateRetryDelay_(); + } + }); + } + + /** + * Implement exponential backoff. + * @private + */ + updateRetryDelay_() { + let newRetryDelay = this.retryDelay_ * this.backoffFactor_; + if (this.maxRetryDelay_ > 0) { + newRetryDelay = Math.min(newRetryDelay, this.maxRetryDelay_); + } + this.retryDelay_ = newRetryDelay; + } + + /** + * Sets the backoff factor. After each attempt, the retry delay is + * multiplied by the backoff factor, which must be at least 1. + * + * @param {number} backoffFactor The factor by which the retry delay should be + * increased after each attempt. + * @return {!mr.Retry} this + */ + setBackoffFactor(backoffFactor) { + mr.Assertions.assert(backoffFactor >= 1); + this.backoffFactor_ = backoffFactor; + return this; + } + + /** + * Sets the maximum retry delay that can be used as a result of the backoff + * factor. If 0, there is no maximum. + * @param {number} maxRetryDelay The maximum number of milliseconds to wait + * before retrying. + * @return {!mr.Retry} this + */ + setMaxRetryDelay(maxRetryDelay) { + mr.Assertions.assert(maxRetryDelay >= 0); + this.maxRetryDelay_ = maxRetryDelay; + return this; + } + + /** + * Causes this object to stop making attempts and puts it in a + * finished state. May be called any time after `start`. + */ + abort() { + this.cleanup_(); + this.resolver_.reject(Error('abort')); + } + + /** + * @private + */ + cleanup_() { + if (this.timerId_ != null) { + // Clean up any timer, because it will be a no-op when it fires. + clearTimeout(this.timerId_); + this.timerId_ = null; + } + + this.isFinished_ = true; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/runtime_error_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/runtime_error_utils.js new file mode 100644 index 00000000000..f3eab537456 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/runtime_error_utils.js @@ -0,0 +1,38 @@ +// 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. + +/** + * @fileoverview Utilities for handling extension API errors. + */ + +goog.module('mr.RunTimeErrorUtils'); +goog.module.declareLegacyNamespace(); + +const Logger = goog.require('mr.Logger'); + + +/** + * Converts chrome.runtime.lastError into an Error object. Should only be + * called from an extension function callback that returns null or undefined. + * By accessing chrome.runtime.lastError, this method has a side effect of + * "handling" the error by preventing it from turning into an unchecked error. + + * + * @param {string} functionName The name of the extension API function. + * @param {!Logger=} logger If there is an error, logs a FINE error message + * with the logger, if provided. + * @return {?Error} An Error object with chrome.runtime.lastError.message, if + * any. + */ +exports.getError = function(functionName, logger = undefined) { + if (!chrome.runtime.lastError) { + return null; + } + const message = functionName + ' failed, chrome.runtime.lastError: ' + + (chrome.runtime.lastError.message || 'Unknown error'); + if (logger) { + logger.fine(message); + } + return new Error(message); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/sink_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/sink_utils.js new file mode 100644 index 00000000000..830ecbe5895 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/sink_utils.js @@ -0,0 +1,187 @@ +// 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. + +/** + * @fileoverview Utilites and settings for media sinks. + + */ + +goog.module('mr.SinkUtils'); +goog.module.declareLegacyNamespace(); + +const PersistentData = goog.require('mr.PersistentData'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); +const Sha1 = goog.require('mr.Sha1'); +const StringUtils = goog.require('mr.StringUtils'); +const base64 = goog.require('mr.base64'); + + +/** + * @implements {PersistentData} + */ +class SinkUtils { + constructor() { + /** + * Used to generate per-profile media sink ids. Persistent data. + * @private {string} + */ + this.receiverIdToken_ = StringUtils.getRandomString(); + + /** + * The model name of the device that was most recently used to start a media + * route. Temporary data. + * @type {!SinkUtils.DeviceData} + */ + this.recentLaunchedDevice = new SinkUtils.DeviceData(null, null); + + /** + * The model name of the device that was most recently discovered. Temporary + * data. + * @type {!SinkUtils.DeviceData} + */ + this.recentDiscoveredDevice = new SinkUtils.DeviceData(null, null); + + /** + * List of IP addresses of devices that are assumed to exist (and not + * discvered on the LAN), for debugging purposes. Persistent data. + * @type {!Array<string>} + */ + this.fixedIpList = []; + + /** + * Persistent data. + * @type {number} + */ + this.castControlPort = 0; + + /** + * The last time cloud sinks were checked. + * @type {number} + */ + this.lastCloudSinkCheckTimeMillis = 0; + + PersistentDataManager.register(this); + } + + /** + * @return {!SinkUtils} + */ + static getInstance() { + if (!SinkUtils.instance_) { + SinkUtils.instance_ = new SinkUtils(); + } + return SinkUtils.instance_; + } + + /** + * Generates ID from the receiver UUID and a per-profile token saved in + * localStorage. + * + * Both DIAL and mDNS use this to generate receiver ID so that it is + * consistent and can be used to deduplicate receivers. For a given token, the + * ID is the same for the same device no matter when it is discovered. + * + * @param {string} uniqueId + * @return {string} receiver ID. + */ + generateId(uniqueId) { + uniqueId = uniqueId.toLowerCase(); + const sha1 = new Sha1(); + sha1.update(uniqueId); + sha1.update(this.receiverIdToken_); + return 'r' + base64.encodeArray(sha1.digest(), true); + } + + /** + * @return {!SinkUtils.DeviceData} Most recent device launched or + * discovered. + */ + getRecentDevice() { + if (this.recentLaunchedDevice.model) return this.recentLaunchedDevice; + + if (this.recentDiscoveredDevice.model) return this.recentDiscoveredDevice; + + return new SinkUtils.DeviceData(null, null); + } + + /** + * @override + */ + getStorageKey() { + return 'SinkUtils'; + } + + /** + * @override + */ + getData() { + return [ + { + 'recentLaunchedDevice': this.recentLaunchedDevice, + 'recentDiscoveredDevice': this.recentDiscoveredDevice + }, + { + 'receiverIdToken': this.receiverIdToken_, + 'fixedIpList': this.fixedIpList.join(','), + 'castControlPort': this.castControlPort, + 'lastCloudSinkCheckTimeMillis': this.lastCloudSinkCheckTimeMillis + } + ]; + } + + /** + * @override + */ + loadSavedData() { + const tempData = PersistentDataManager.getTemporaryData(this); + if (tempData) { + this.recentLaunchedDevice = tempData['recentLaunchedDevice'] || + new SinkUtils.DeviceData(null, null); + this.recentDiscoveredDevice = tempData['recentDiscoveredDevice'] || + new SinkUtils.DeviceData(null, null); + } + + const persistentData = PersistentDataManager.getPersistentData(this); + if (persistentData) { + this.receiverIdToken_ = + persistentData['receiverIdToken'] || StringUtils.getRandomString(); + this.fixedIpList = (persistentData['fixedIpList'] && + persistentData['fixedIpList'].split(',')) || + []; + this.castControlPort = persistentData['castControlPort'] || 0; + this.lastCloudSinkCheckTimeMillis = + persistentData['lastCloudSinkCheckTimeMillis'] || 0; + } + } +} + + +/** @private {SinkUtils} */ +SinkUtils.instance_ = null; + + +/** + * The device data to keep track of. + */ +SinkUtils.DeviceData = class { + /** + * @param {?string} modelName + * @param {?string} ipAddress + */ + constructor(modelName, ipAddress) { + /** + * @type {?string} + * @export + */ + this.model = modelName; + + /** + * @type {?string} + * @export + */ + this.ip = ipAddress; + } +}; + +exports = SinkUtils; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/common/xhr_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/xhr_utils.js new file mode 100644 index 00000000000..0ce839efb46 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/common/xhr_utils.js @@ -0,0 +1,72 @@ +// 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. + +/** + * @fileoverview Utilities for dealing with XmlHttpRequests. + */ + +goog.provide('mr.XhrUtils'); + +goog.require('mr.Logger'); + + +/** + * Logs the outcome of a XmlHttpRequest. + * @param {mr.Logger} logger Where to log. + * @param {string} action The operation that created the Fetch. + * @param {string} method The HTTP method. + * @param {!XMLHttpRequest} xhr The response from Fetch. + */ +mr.XhrUtils.logRawXhr = function(logger, action, method, xhr) { + const logString = mr.XhrUtils.getStatusString_( + action, method, xhr.responseURL, xhr.status, xhr.statusText); + if (mr.XhrUtils.isSuccess(xhr)) { + logger.info(logString); + } else { + logger.fine(logString); + } +}; + + +/** + * Returns true if the given XMLHttpRequest represents a successful response. + * @param {!XMLHttpRequest} xhr + * @return {boolean} + */ +mr.XhrUtils.isSuccess = function(xhr) { + return xhr.status >= 200 && xhr.status <= 299; +}; + + +/** + * Returns a loggable string with the given parameters. + * @param {string} action + * @param {string} method + * @param {string} url + * @param {number} status + * @param {string} statusText + * @return {string} + * @private + */ +mr.XhrUtils.getStatusString_ = function( + action, method, url, status, statusText) { + return `[${action}]: ${method} ${url} => ${status} (${statusText})`; +}; + + +/** + * Parses xmlText into an XML document. + * @param {string} xmlText A serialized XML document. + * @return {?Document} An XML document if the parse was successful, null + * otherwise. + */ +mr.XhrUtils.parseXml = function(xmlText) { + const xml = new DOMParser().parseFromString(xmlText, 'text/xml'); + // A failed parse returns an HTML document with a <parsererror> element. + return xml.getElementsByTagNameNS( + 'http://www.w3.org/1999/xhtml', 'parsererror') + .length ? + null : + xml; +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity.js new file mode 100644 index 00000000000..264e20928c6 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity.js @@ -0,0 +1,22 @@ +// 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. + +/** + * @fileoverview DIAL activity (locally launched or discovered). + */ + +goog.provide('mr.dial.Activity'); + +mr.dial.Activity = class { + /** + * @param {!mr.Route} route + * @param {!string} appName + */ + constructor(route, appName) { + /** @type {!mr.Route} */ + this.route = route; + /** @type {string} */ + this.appName = appName; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity_records.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity_records.js new file mode 100644 index 00000000000..112ec31bd15 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity_records.js @@ -0,0 +1,156 @@ +// 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. + +goog.module('mr.dial.ActivityRecords'); + +const ActivityCallbacks = goog.require('mr.dial.ActivityCallbacks'); +const PersistentData = goog.require('mr.PersistentData'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); + + +/** + * Keeps track of DIAL activities. + * @implements {PersistentData} + */ +const ActivityRecords = class { + /** + * @param {!ActivityCallbacks} activityCallbacks + */ + constructor(activityCallbacks) { + /** + * Maps route ID to the corresponding Activity. + * @private {!Map<string, !mr.dial.Activity>} + */ + this.routeIdToAppInfo_ = new Map(); + + /** @private @const {!ActivityCallbacks} */ + this.activityCallbacks_ = activityCallbacks; + } + + /** + * Restores routeIdToAppInfo_ from saved data. + */ + init() { + PersistentDataManager.register(this); + } + + /** + * Removes all activities. + */ + clear() { + this.routeIdToAppInfo_.clear(); + } + + /** + * Adds a new activity. If the activity already exists, this is no-op. + * @param {!mr.dial.Activity} activity + */ + add(activity) { + if (this.getByRouteId(activity.route.id)) { + return; + } + this.routeIdToAppInfo_.set(activity.route.id, activity); + this.activityCallbacks_.onActivityAdded(activity); + } + + /** + * Returns the activity corresponding to the given route ID, or null if there + * is none. + * @param {string} routeId + * @return {?mr.dial.Activity} + */ + getByRouteId(routeId) { + return this.routeIdToAppInfo_.get(routeId) || null; + } + + /** + * Returns the activity corresponding to the given sink ID, or null if there + * is none. + * @param {string} sinkId + * @return {?mr.dial.Activity} + */ + getBySinkId(sinkId) { + for (let [routeId, activity] of this.routeIdToAppInfo_) { + if (activity.route.sinkId == sinkId) { + return /** @type {!mr.dial.Activity} */ (activity); + } + } + return null; + } + + /** + * Removes the activity associated with the given sink ID. + * @param {string} sinkId + */ + removeBySinkId(sinkId) { + const activity = this.getBySinkId(sinkId); + if (activity) { + this.routeIdToAppInfo_.delete(activity.route.id); + this.activityCallbacks_.onActivityRemoved(activity); + } + } + + /** + * Removes the activity associated with the given route ID. + * @param {string} routeId + */ + removeByRouteId(routeId) { + const activity = this.routeIdToAppInfo_.get(routeId); + if (activity) { + this.routeIdToAppInfo_.delete(routeId); + this.activityCallbacks_.onActivityRemoved(activity); + } + } + + /** + * Returns the list of routes associated with the current list of activities. + * @return {!Array<!mr.Route>} + */ + getRoutes() { + return Array.from( + this.routeIdToAppInfo_.values(), activity => activity.route); + } + + /** + * Returns the current list of acitvities. + * @return {!Array<!mr.dial.Activity>} + */ + getActivities() { + return Array.from(this.routeIdToAppInfo_.values()); + } + + /** + * Returns the number of activities. + * @return {number} + */ + getActivityCount() { + return this.routeIdToAppInfo_.size; + } + + /** + * @override + */ + getStorageKey() { + return 'dial.ActivityRecords'; + } + + /** + * @override + */ + getData() { + return [Array.from(this.routeIdToAppInfo_)]; + } + + /** + * @override + */ + loadSavedData() { + const savedData = PersistentDataManager.getTemporaryData(this); + if (savedData) { + this.routeIdToAppInfo_ = new Map(savedData); + } + } +}; + +exports = ActivityRecords; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity_records_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity_records_test.js new file mode 100644 index 00000000000..38ff262fba0 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_activity_records_test.js @@ -0,0 +1,100 @@ +// 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. + +goog.module('mr.dial.ActivityRecordsTest'); +goog.setTestOnly('mr.dial.ActivityRecordsTest'); + +const Activity = goog.require('mr.dial.Activity'); +const ActivityRecords = goog.require('mr.dial.ActivityRecords'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); +const Route = goog.require('mr.Route'); +const UnitTestUtils = goog.require('mr.UnitTestUtils'); + +describe('DIAL ActivityRecords Tests', function() { + let records; + let mockCallbacks; + let activity1; + let activity2; + + beforeEach(function() { + UnitTestUtils.mockMojoApi(); + UnitTestUtils.mockChromeApi(); + let route = new Route( + 'routeId1', 'presentationId1', 'sinkId1', null, false, 'description1', + 'imageUrl1'); + activity1 = new Activity(route, 'app1'); + route = new Route( + 'routeId2', 'presentationId2', 'sinkId2', null, true, 'description2', + 'imageUrl2'); + activity2 = new Activity(route, 'app2'); + mockCallbacks = jasmine.createSpyObj( + 'ActivityCallbacks', + ['onActivityAdded', 'onActivityRemoved', 'onActivityUpdated']); + records = new ActivityRecords(mockCallbacks); + records.init(); + }); + + afterEach(function() { + PersistentDataManager.clear(); + UnitTestUtils.restoreChromeApi(); + }); + + it('Add activity', function() { + records.add(activity1); + expect(records.getByRouteId(activity1.route.id)).toEqual(activity1); + expect(mockCallbacks.onActivityAdded.calls.count()).toBe(1); + expect(mockCallbacks.onActivityAdded).toHaveBeenCalledWith(activity1); + records.add(activity1); + expect(mockCallbacks.onActivityAdded.calls.count()).toBe(1); + }); + + it('Get activity and route', function() { + records.add(activity1); + expect(records.getByRouteId(activity1.route.id)).toEqual(activity1); + expect(records.getBySinkId(activity1.route.sinkId)).toEqual(activity1); + expect(records.getRoutes()).toEqual([activity1.route]); + records.add(activity2); + expect(records.getByRouteId(activity2.route.id)).toEqual(activity2); + expect(records.getBySinkId(activity2.route.sinkId)).toEqual(activity2); + expect(records.getRoutes()).toEqual([activity1.route, activity2.route]); + }); + + it('Remove activity', function() { + records.add(activity1); + records.add(activity2); + records.removeByRouteId(activity2.route.id); + expect(mockCallbacks.onActivityRemoved.calls.count()).toBe(1); + expect(mockCallbacks.onActivityRemoved).toHaveBeenCalledWith(activity2); + records.removeByRouteId(activity2.route.id); + expect(mockCallbacks.onActivityRemoved.calls.count()).toBe(1); + expect(records.getByRouteId(activity2.route.id)).toEqual(null); + expect(records.getBySinkId(activity2.route.sinkId)).toEqual(null); + expect(records.getRoutes()).toEqual([activity1.route]); + + records.removeBySinkId(activity1.route.sinkId); + expect(mockCallbacks.onActivityRemoved.calls.count()).toBe(2); + expect(mockCallbacks.onActivityRemoved).toHaveBeenCalledWith(activity1); + expect(records.getRoutes()).toEqual([]); + }); + + it('Save without any data', function() { + expect(records.getRoutes()).toEqual([]); + PersistentDataManager.suspendForTest(); + records = new ActivityRecords(mockCallbacks); + records.loadSavedData(); + expect(records.getRoutes()).toEqual([]); + }); + + it('Save with data', function() { + records.add(activity1); + records.add(activity2); + PersistentDataManager.suspendForTest(); + records = new ActivityRecords(mockCallbacks); + records.loadSavedData(); + expect(JSON.stringify(records.getRoutes())).toEqual(JSON.stringify([ + activity1.route, activity2.route + ])); + }); + +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_analytics.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_analytics.js new file mode 100644 index 00000000000..9abe0f835a5 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_analytics.js @@ -0,0 +1,119 @@ +// 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. + +/** + * @fileoverview Defines UMA analytics specific to the DIAL provider. + */ + +goog.provide('mr.DialAnalytics'); + +goog.require('mr.Analytics'); + + +/** + * Contains all analytics logic for the DIAL provider. + * @const {*} + */ +mr.DialAnalytics = {}; + + +/** @enum {string} */ +mr.DialAnalytics.Metric = { + DEVICE_DESCRIPTION_FAILURE: 'MediaRouter.Dial.Device.Description.Failure', + DEVICE_DESCRIPTION_FROM_CACHE: 'MediaRouter.Dial.Device.Description.Cached', + DIAL_CREATE_ROUTE: 'MediaRouter.Dial.Create.Route', + NON_CAST_DISCOVERY: 'MediaRouter.Dial.Sink.Discovered.NonCast' +}; + + +/** + * Possible values for the route creation analytics. + * @enum {number} + */ +mr.DialAnalytics.DialRouteCreation = { + FAILED_NO_SINK: 0, + ROUTE_CREATED: 1, + NO_APP_INFO: 2, + FAILED_LAUNCH_APP: 3 +}; + + +/** + * Possible values for device description failures. + * @enum {number} + */ +mr.DialAnalytics.DeviceDescriptionFailures = { + ERROR: 0, + PARSE: 1, + EMPTY: 2 +}; + + +/** + * Records analytics around route creation. + * @param {mr.DialAnalytics.DialRouteCreation} value + */ +mr.DialAnalytics.recordCreateRoute = function(value) { + mr.Analytics.recordEnum( + mr.DialAnalytics.Metric.DIAL_CREATE_ROUTE, value, + mr.DialAnalytics.DialRouteCreation); +}; + + +/** + * Records a failure with the device description. + * @param {!mr.DialAnalytics.DeviceDescriptionFailures} value The failure + * reason. + */ +mr.DialAnalytics.recordDeviceDescriptionFailure = function(value) { + mr.Analytics.recordEnum( + mr.DialAnalytics.Metric.DEVICE_DESCRIPTION_FAILURE, value, + mr.DialAnalytics.DeviceDescriptionFailures); +}; + + +/** + * Records that device description was retreived from the cache. + */ +mr.DialAnalytics.recordDeviceDescriptionFromCache = function() { + mr.Analytics.recordEvent( + mr.DialAnalytics.Metric.DEVICE_DESCRIPTION_FROM_CACHE); +}; + + +/** + * Records that a device was discovered by DIAL that didn't support the cast + * protocol. + */ +mr.DialAnalytics.recordNonCastDiscovery = function() { + mr.Analytics.recordEvent(mr.DialAnalytics.Metric.NON_CAST_DISCOVERY); +}; + + +/** + * Histogram name for available DIAL devices count. + * @private @const {string} + */ +mr.DialAnalytics.AVAILABLE_DEVICES_COUNT_ = + 'MediaRouter.Dial.AvailableDevicesCount'; + + +/** + * Histogram name for known DIAL devices count. + * @private @const {string} + */ +mr.DialAnalytics.KNOWN_DEVICES_COUNT_ = 'MediaRouter.Dial.KnownDevicesCount'; + + +/** + * Records device counts. + * @param {!mr.DeviceCounts} deviceCounts + */ +mr.DialAnalytics.recordDeviceCounts = function(deviceCounts) { + mr.Analytics.recordSmallCount( + mr.DialAnalytics.AVAILABLE_DEVICES_COUNT_, + deviceCounts.availableDeviceCount); + mr.Analytics.recordSmallCount( + mr.DialAnalytics.KNOWN_DEVICES_COUNT_, deviceCounts.knownDeviceCount); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_analytics_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_analytics_test.js new file mode 100644 index 00000000000..37a762b4bf4 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_analytics_test.js @@ -0,0 +1,88 @@ +// 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. + +goog.setTestOnly(); +goog.require('mr.DialAnalytics'); + +describe('Dial Analytics', function() { + + beforeEach(function() { + chrome.metricsPrivate = jasmine.createSpyObj( + ['recordSmallCount', 'recordUserAction', 'recordValue']); + }); + + it('should record a Dial.Create.Route result', function() { + const testConfig = { + 'metricName': 'MediaRouter.Dial.Create.Route', + 'type': 'histogram-linear', + 'min': 1, + 'max': 4, + 'buckets': 5 + }; + let numCalls = 0; + for (key in mr.DialAnalytics.DialRouteCreation) { + const value = mr.DialAnalytics.DialRouteCreation[key]; + mr.DialAnalytics.recordCreateRoute(value); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(++numCalls); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, value); + } + }); + + it('should not record an unknown Dial.Create.Route result', function() { + mr.DialAnalytics.recordCreateRoute('test'); + expect(chrome.metricsPrivate.recordValue).not.toHaveBeenCalled(); + }); + + it('should record a Dial.Device.Description.Failure result', function() { + const testConfig = { + 'metricName': 'MediaRouter.Dial.Device.Description.Failure', + 'type': 'histogram-linear', + 'min': 1, + 'max': 3, + 'buckets': 4 + }; + let numCalls = 0; + for (key in mr.DialAnalytics.DeviceDescriptionFailures) { + const value = mr.DialAnalytics.DeviceDescriptionFailures[key]; + mr.DialAnalytics.recordDeviceDescriptionFailure(value); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(++numCalls); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, value); + } + }); + + it('should not record an unknown Dial.Device.Description.Failure', + function() { + mr.DialAnalytics.recordDeviceDescriptionFailure('test'); + expect(chrome.metricsPrivate.recordValue).not.toHaveBeenCalled(); + }); + + it('should record a Device Description From Cache action', function() { + mr.DialAnalytics.recordDeviceDescriptionFromCache(); + expect(chrome.metricsPrivate.recordUserAction.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordUserAction) + .toHaveBeenCalledWith('MediaRouter.Dial.Device.Description.Cached'); + }); + + it('should record a non-Cast Sink Discovery action', function() { + mr.DialAnalytics.recordNonCastDiscovery(); + expect(chrome.metricsPrivate.recordUserAction.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordUserAction) + .toHaveBeenCalledWith('MediaRouter.Dial.Sink.Discovered.NonCast'); + }); + + it('DeviceCounts is recorded with recordSmallCount', () => { + const deviceCounts = {availableDeviceCount: 5, knownDeviceCount: 8}; + mr.DialAnalytics.recordDeviceCounts(deviceCounts); + expect(chrome.metricsPrivate.recordSmallCount.calls.count()).toBe(2); + let args = chrome.metricsPrivate.recordSmallCount.calls.argsFor(0); + expect(args[0]).toBe(mr.DialAnalytics.AVAILABLE_DEVICES_COUNT_); + expect(args[1]).toBe(deviceCounts.availableDeviceCount); + + args = chrome.metricsPrivate.recordSmallCount.calls.argsFor(1); + expect(args[0]).toBe(mr.DialAnalytics.KNOWN_DEVICES_COUNT_); + expect(args[1]).toBe(deviceCounts.knownDeviceCount); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_app_discovery_service.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_app_discovery_service.js new file mode 100644 index 00000000000..d612fa90702 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_app_discovery_service.js @@ -0,0 +1,480 @@ +// 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. + +goog.module('mr.dial.AppDiscoveryService'); + +const ActivityRecords = goog.require('mr.dial.ActivityRecords'); +const DialClient = goog.require('mr.dial.Client'); +const DialSink = goog.require('mr.dial.Sink'); +const DialSinkAppStatus = goog.require('mr.dial.SinkAppStatus'); +const Logger = goog.require('mr.Logger'); +const PersistentData = goog.require('mr.PersistentData'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); +const PromiseUtils = goog.require('mr.PromiseUtils'); +const SinkDiscoveryService = goog.require('mr.dial.SinkDiscoveryService'); + + +/** + * Service for determining whether a sink supports a given DIAL app. + * @implements {PersistentData} + */ +const AppDiscoveryService = class { + /** + * @param {!SinkDiscoveryService} discoveryService + * @param {!ActivityRecords} activityRecords + */ + constructor(discoveryService, activityRecords) { + /** @private @const {?Logger} */ + this.logger_ = Logger.getInstance('mr.dial.AppDiscoveryService'); + + /** + * Names of applications that are actively being queried for. + * @private {!Set<string>} + */ + this.registeredApps_ = new Set(); + + /** + * @private @const {!SinkDiscoveryService} + */ + this.discoveryService_ = discoveryService; + + /** + * @private @const {!ActivityRecords} + */ + this.activityRecords_ = activityRecords; + + /** + * Keeps track of pending requests to avoid duplication. Values are + * Promises returning strings of the form <sink.getId()>:<appName>. + * @private @const {!Map<string, !Promise<!DialClient.AppInfo>>} + */ + this.pendingRequests_ = new Map(); + + /** + * DIAL clients used by this service. Keys are sink ids. + * @private @const {!Map<string, !DialClient.Client>} + */ + this.clientsBySinkId_ = new Map(); + + /** + * The timeout ID for the next scan. + * @private {?number} + */ + this.timeoutId_ = null; + + /** + * Whether the service is running. + * @private {boolean} + */ + this.running_ = false; + } + + init() { + PersistentDataManager.register(this); + } + + /** + * Starts discovery. + */ + start() { + this.logger_.info('Starting periodic scanning.'); + if (!this.running_) { + this.running_ = true; + this.doScan_(); + } + } + + /** + * Stops discovery. Also aborts all outstanding discovery requests. + */ + stop() { + this.logger_.info('Stopping periodic scanning.'); + if (!this.running_) return; + this.running_ = false; + if (this.timeoutId_) { + clearTimeout(this.timeoutId_); + this.timeoutId_ = null; + } + this.pendingRequests_.clear(); + } + + /** + * Registers an application name that should be queried. Starts periodic + * scanning if it hasn't already started. Otherwise, issues an out-of-band + * query for the app against all discovered sinks. + * @param {string} appName + */ + registerApp(appName) { + if (this.registeredApps_.has(appName) && + !this.anyUnknownAppStatus_(appName)) { + // No need to scan since status for appName is known for all sinks. + return; + } + + this.registeredApps_.add(appName); + + if (this.discoveryService_.getSinkCount() == 0) { + // No sinks, no need to scan. + return; + } + if (!this.running_) { + this.start(); + } else { + this.discoveryService_.getSinks().forEach( + this.doScanSinkForApp_.bind(this, appName)); + } + } + + /** + * Unregisters an application name. + * @param {string} appName + */ + unregisterApp(appName) { + this.registeredApps_.delete(appName); + } + + /** + * @return {!Array<string>} + */ + getRegisteredApps() { + return Array.from(this.registeredApps_); + } + + /** + * @return {number} + */ + getAppCount() { + return this.registeredApps_.size; + } + + /** + * Issues app info queries and updates sink app status and activities + * according to results. Queries will be made for the following: + * 1) All registered apps, against all discovered sinks. Note that a (app, + * sink) combination is only queried if its status is unknown, or if enough + * time has elapsed since its previous status was known. + * 2) Each activity's app against its sink. + * Once all queries have returned and all updates have been made, schedules a + * scan in the future. + * @private + */ + doScan_() { + this.logger_.info('Start app status scan.'); + const promises = []; + + // Scans all sinks against all registered apps. + this.discoveryService_.getSinks().forEach(sink => { + promises.push.apply(promises, this.scanSink(sink)); + }); + + // Scans all activities. + this.activityRecords_.getActivities().forEach(activity => { + promises.push(this.scanActivity_(activity)); + }); + + PromiseUtils.allSettled(promises).then(() => { + if (this.running_ && !this.timeoutId_) { + this.logger_.fine('Scan complete; scheduling for next scan.'); + // NOTE(imcheng): setTimeout with a large delay does not work well in + // event pages. Instead we should be using chrome.alarms API, but note + // that it is subject to a minmum delay of 1 minute. + this.timeoutId_ = setTimeout(() => { + this.doRescan_(); + }, AppDiscoveryService.CHECK_INTERVAL_MILLIS); + } + }); + } + + /** + * Clears the timer and starts a round of scanning. Only valid when called + * from a timer. + * @private + */ + doRescan_() { + this.logger_.fine('Start app status scan (timer-based)'); + this.timeoutId_ = null; + this.doScan_(); + } + + /** + * Asynchronously scans a sink against all registered apps and updates its app + * status map if needed. + * @param {!DialSink} sink + * @return {!Array<!Promise<void>>} If the sink does not support app + * the getting app info, returns an empty array. Otherwise, returns a list + * of Promises, each corresponding to a registered app, resolved when the + * sink's app status has been updated. + */ + scanSink(sink) { + if (!sink.supportsAppAvailability()) { + return []; + } + + const promises = []; + for (let appName of this.registeredApps_) { + promises.push(this.doScanSinkForApp_(appName, sink)); + } + return promises; + } + + /** + * Issues an app info query for the given app to the given sink, and updates + * the sink's app status map with the response. + * @param {string} appName + * @param {!DialSink} sink + * @return {!Promise<void>} Resolved when the sink's app status has been + * updated. + * @private + */ + doScanSinkForApp_(appName, sink) { + if (sink.getAppStatus(appName) != DialSinkAppStatus.UNKNOWN && + Date.now() - sink.getAppStatusTimeStamp(appName) < + AppDiscoveryService.CACHE_PERIOD_) { + // App status already known and not expired. + return Promise.resolve(); + } + if (!sink.supportsAppAvailability()) { + return Promise.resolve(); + } + this.logger_.fine( + 'Querying ' + sink.getId() + ' for ' + appName + + ' to update app status'); + return this.getAppInfo_(sink, appName) + .then( + appInfo => { + const newAppStatus = this.getAvailabilityFromAppInfo_(appInfo); + this.maybeUpdateAppStatus_(sink, appName, newAppStatus); + }, + e => { + // Some devices return NOT_FOUND for GetAppInfo to indicate the + // app is unavailable. + if (e instanceof DialClient.AppInfoNotFoundError) { + this.maybeUpdateAppStatus_( + sink, appName, DialSinkAppStatus.UNAVAILABLE); + return; + } + this.logger_.warning( + 'Failed to process app availability; ' + sink.getId() + + ' does not support app availability'); + sink.setSupportsAppAvailability(false); + }); + } + + /** + * Issues an app info query for the given activity's app to the activity's + * sink, and updates the activity records if the app is no longer running. + * @param {!mr.dial.Activity} activity + * @return {!Promise<void>} Resolved when the activity record has possibly + * been updated. + * @private + */ + scanActivity_(activity) { + const sink = this.discoveryService_.getSinkById(activity.route.sinkId); + if (!sink) { + this.logger_.warning( + 'Activity refers to nonexistent sink: ' + activity.route.id); + return Promise.resolve(); + } + if (!sink.supportsAppAvailability()) { + return Promise.resolve(); + } + const appName = activity.appName; + this.logger_.fine( + 'Querying ' + sink.getId() + ' for ' + appName + ' to update activity'); + return this.getAppInfo_(sink, appName) + .then( + appInfo => this.maybeUpdateActivityRecord_( + /** @type {!DialSink} */ (sink), appName, appInfo), + e => this.doRemoveActivityRecord_( + /** @type {!DialSink} */ (sink), appName)); + } + + /** + * Gets the DIAL client associated with the given sink, or creates one if it + * does not exist. + * @param {!DialSink} sink + * @return {!DialClient.Client} A client instance for the given sink. + * @private + */ + getDialClient_(sink) { + let client = this.clientsBySinkId_.get(sink.getId()); + if (!client) { + client = new DialClient.Client(sink); + this.logger_.fine('Created DIAL client for ' + sink.getId()); + this.clientsBySinkId_.set(sink.getId(), client); + } + return client; + } + + /** + * Issues an app info query with the given sink's DIAL client. A query will + * not be issued if there is already a same one pending. + * @param {!DialSink} sink + * @param {string} appName + * @return {!Promise<!DialClient.AppInfo>} Resolved with the query response, + * or rejected on error. + * @private + */ + getAppInfo_(sink, appName) { + const requestId = AppDiscoveryService.getRequestId_(sink, appName); + let promise = this.pendingRequests_.get(requestId); + if (promise) { + return promise; + } + + promise = this.getDialClient_(sink).getAppInfo(appName); + this.pendingRequests_.set(requestId, promise); + const cleanup = () => { + this.pendingRequests_.delete(requestId); + }; + promise.then(cleanup, cleanup); + return promise; + } + + /** + * Translates the given app info result into a DialSinkAppStatus value. + * @param {!DialClient.AppInfo} appInfo + * @return {DialSinkAppStatus} + * @private + */ + getAvailabilityFromAppInfo_(appInfo) { + if (appInfo.name == 'Netflix') { + return this.getAppStatusFromNetflixAppInfo_(appInfo); + } else { + switch (appInfo.state) { + case DialClient.DialAppState.RUNNING: + case DialClient.DialAppState.STOPPED: + return DialSinkAppStatus.AVAILABLE; + default: + return DialSinkAppStatus.UNAVAILABLE; + } + } + } + + /** + * Returns app status from a Netflix app info. + * @param {!DialClient.AppInfo} appInfo The app info from DIAL GET. + * @return {DialSinkAppStatus} + * @private + */ + getAppStatusFromNetflixAppInfo_(appInfo) { + const isNetflixWebsocket = + appInfo.extraData && appInfo.extraData['capabilities'] == 'websocket'; + if (isNetflixWebsocket && + (appInfo.state == DialClient.DialAppState.RUNNING || + appInfo.state == DialClient.DialAppState.STOPPED)) { + return DialSinkAppStatus.AVAILABLE; + } else { + return DialSinkAppStatus.UNAVAILABLE; + } + } + + /** + * Returns the request ID to use for an app info request. + * @param {!DialSink} sink + * @param {string} appName + * @return {string} + * @private + */ + static getRequestId_(sink, appName) { + return sink.getId() + ':' + appName; + } + + /** + * Checks whether the status of an app on a sink has changed, and if so + * notifies discovery service. + * @param {!DialSink} sink + * @param {string} appName + * @param {DialSinkAppStatus} newAppStatus + * @private + */ + maybeUpdateAppStatus_(sink, appName, newAppStatus) { + this.logger_.fine( + 'Got app status ' + newAppStatus + ' from ' + sink.getId() + ' for ' + + appName); + const oldAppStatus = sink.getAppStatus(appName); + sink.setAppStatus(appName, newAppStatus); + if (newAppStatus != oldAppStatus) { + this.discoveryService_.onAppStatusChanged(appName, sink); + } + } + + /** + * Checks whether the given app is no longer running on the given sink, and if + * so notifies activity records. + * @param {!DialSink} sink + * @param {string} appName + * @param {!DialClient.AppInfo} appInfo + * @private + */ + maybeUpdateActivityRecord_(sink, appName, appInfo) { + if (appInfo.state != DialClient.DialAppState.RUNNING) { + this.doRemoveActivityRecord_(sink, appName); + } + } + + /** + * Removes the activity record with the given sink and app name, if it exists. + * @param {!DialSink} sink + * @param {string} appName + * @private + */ + doRemoveActivityRecord_(sink, appName) { + const activity = this.activityRecords_.getBySinkId(sink.getId()); + if (activity && activity.appName == appName) { + this.activityRecords_.removeByRouteId(activity.route.id); + } + } + + /** + * Checks if there is a sink whose status of appName is unknown. + * @param {string} appName + * @return {boolean} + * @private + */ + anyUnknownAppStatus_(appName) { + return this.discoveryService_.getSinks().some( + s => s.getAppStatus(appName) == DialSinkAppStatus.UNKNOWN); + } + + /** + * @override + */ + getStorageKey() { + return 'dial.AppDiscoveryService'; + } + + /** + * @override + */ + getData() { + return [this.getRegisteredApps()]; + } + + /** + * @override + */ + loadSavedData() { + const savedData = PersistentDataManager.getTemporaryData(this); + this.registeredApps_ = new Set(savedData || []); + } +}; + + +/** + * The interval for periodic scanning. + * @package @const {number} + */ +AppDiscoveryService.CHECK_INTERVAL_MILLIS = 60 * 1000; + + +/** + * The amount of time an availability result for an app (both AVAILABLE and + * UNAVAILABLE) will be cached for. + * @private @const {number} + */ +AppDiscoveryService.CACHE_PERIOD_ = 60 * 60 * 1000; + + +exports = AppDiscoveryService; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_app_discovery_service_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_app_discovery_service_test.js new file mode 100644 index 00000000000..cd7edeac16b --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_app_discovery_service_test.js @@ -0,0 +1,362 @@ +// 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. + +goog.module('mr.dial.AppDiscoveryServiceTest'); +goog.setTestOnly('mr.dial.AppDiscoveryServiceTest'); + +const Activity = goog.require('mr.dial.Activity'); +const ActivityRecords = goog.require('mr.dial.ActivityRecords'); +const AppDiscoveryService = goog.require('mr.dial.AppDiscoveryService'); +const DialClient = goog.require('mr.dial.Client'); +const DialSink = goog.require('mr.dial.Sink'); +const DialSinkAppStatus = goog.require('mr.dial.SinkAppStatus'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); +const Route = goog.require('mr.Route'); +const UnitTestUtils = goog.require('mr.UnitTestUtils'); + +describe('DIAL AppDiscoveryService Tests', function() { + let activityRecords; + let service; + let mockClock; + let mockDiscoveryService; + let mockActivityCallbacks; + let sink1; + let sink2; + let sink3; + let mockDialClient; + let stoppedAppInfo; + let runningAppInfo; + let installableAppInfo; + let stoppedAppInfoWithWebsocket; + + const setUpGetAppInfoResponse = function(appInfo) { + mockDialClient.getAppInfo.and.callFake(() => Promise.resolve(appInfo)); + }; + + const setUpGetAppInfoError = function() { + mockDialClient.getAppInfo.and.callFake( + () => Promise.reject(new Error('getAppInfo failed'))); + }; + + const setUpGetAppInfoNotFoundError = function() { + mockDialClient.getAppInfo.and.callFake( + () => Promise.reject(new DialClient.AppInfoNotFoundError())); + }; + + beforeEach(function() { + mockClock = UnitTestUtils.useMockClockAndPromises(); + + mockDiscoveryService = jasmine.createSpyObj( + 'discoveryService', + ['getSinks', 'getSinkById', 'getSinkCount', 'onAppStatusChanged']); + mockActivityCallbacks = jasmine.createSpyObj( + 'activityCallbacks', + ['onActivityAdded', 'onActivityRemoved', 'onActivityUpdated']); + activityRecords = new ActivityRecords(mockActivityCallbacks); + service = new AppDiscoveryService(mockDiscoveryService, activityRecords); + + sink1 = new DialSink('sink1', '1').setSupportsAppAvailability(true); + sink2 = new DialSink('sink2', '2').setSupportsAppAvailability(true); + sink3 = new DialSink('sink3', '3').setSupportsAppAvailability(false); + mockDialClient = jasmine.createSpyObj('dialClient', ['getAppInfo']); + spyOn(AppDiscoveryService.prototype, 'getDialClient_') + .and.returnValue(mockDialClient); + stoppedAppInfo = {'state': DialClient.DialAppState.STOPPED}; + runningAppInfo = {'state': DialClient.DialAppState.RUNNING}; + installableAppInfo = {'state': DialClient.DialAppState.INSTALLABLE}; + stoppedAppInfoWithWebsocket = { + 'name': 'Netflix', + 'state': DialClient.DialAppState.STOPPED, + 'extraData': {'capabilities': 'websocket'} + }; + + chrome.runtime = { + id: 'fakeId', + getManifest: function() { + return {version: 'fakeVersion'}; + } + }; + }); + + afterEach(function() { + service.stop(); + UnitTestUtils.restoreRealClockAndPromises(); + PersistentDataManager.clear(); + }); + + describe('Tests registerApp', function() { + beforeEach(function() { + mockDiscoveryService.getSinks.and.returnValue([sink1, sink2, sink3]); + mockDiscoveryService.getSinkCount.and.returnValue(3); + }); + + const expectAppStatus = function(expectedAppStatus, appName) { + service.init(); + + expect(sink1.getAppStatus(appName)).toEqual(DialSinkAppStatus.UNKNOWN); + expect(sink2.getAppStatus(appName)).toEqual(DialSinkAppStatus.UNKNOWN); + expect(sink3.getAppStatus(appName)).toEqual(DialSinkAppStatus.UNKNOWN); + + service.registerApp(appName); + + service.start(); + // Let internal promises to resolve or reject. + mockClock.tick(1); + + expect(sink1.getAppStatus(appName)).toEqual(expectedAppStatus); + expect(sink2.getAppStatus(appName)).toEqual(expectedAppStatus); + expect(sink3.getAppStatus(appName)).toEqual(DialSinkAppStatus.UNKNOWN); + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + }; + + it('Response indicates app was stopped', function() { + setUpGetAppInfoResponse(stoppedAppInfo); + expectAppStatus(DialSinkAppStatus.AVAILABLE, 'YouTube'); + }); + + it('Response indicates app was running', function() { + setUpGetAppInfoResponse(runningAppInfo); + expectAppStatus(DialSinkAppStatus.AVAILABLE, 'YouTube'); + }); + + it('Response indicates app was installable', function() { + setUpGetAppInfoResponse(installableAppInfo); + expectAppStatus(DialSinkAppStatus.UNAVAILABLE, 'YouTube'); + }); + + it('Response has invalid app info', function() { + setUpGetAppInfoError(); + expectAppStatus(DialSinkAppStatus.UNKNOWN, 'YouTube'); + expect(sink1.supportsAppAvailability()).toBe(false); + expect(sink2.supportsAppAvailability()).toBe(false); + }); + + it('Response indicates not found', function() { + setUpGetAppInfoNotFoundError(); + expectAppStatus(DialSinkAppStatus.UNAVAILABLE, 'YouTube'); + }); + + it('Netflix with special stopped info', function() { + setUpGetAppInfoResponse(stoppedAppInfoWithWebsocket); + expectAppStatus(DialSinkAppStatus.AVAILABLE, 'Netflix'); + }); + + it('Netflix with normal stopped info', function() { + stoppedAppInfo.name = 'Netflix'; + setUpGetAppInfoResponse(stoppedAppInfo); + expectAppStatus(DialSinkAppStatus.UNAVAILABLE, 'Netflix'); + }); + + it('No new query generated when registering existing app with known status', + function() { + setUpGetAppInfoResponse(stoppedAppInfo); + expectAppStatus(DialSinkAppStatus.AVAILABLE, 'YouTube'); + // register again + service.registerApp('YouTube'); + mockClock.tick(1); + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + // unregister does nothing because sink already had the app status. + service.unregisterApp('YouTube'); + service.registerApp('YouTube'); + mockClock.tick(1); + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + // unless clear app status first. + sink1.clearAppStatus(); + sink2.clearAppStatus(); + service.unregisterApp('YouTube'); + service.registerApp('YouTube'); + mockClock.tick(1); + expect(mockDialClient.getAppInfo.calls.count()).toBe(4); + }); + + it('No new query generated when register existing app with unknown status', + function() { + setUpGetAppInfoError(); + expectAppStatus(DialSinkAppStatus.UNKNOWN, 'YouTube'); + // Make sink1 have known status. But sink2 has unknown status. + sink1.setAppStatus('YouTube', DialSinkAppStatus.UNAVAILABLE); + // register again + service.registerApp('YouTube'); + mockClock.tick(1); + // No re-query + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + }); + }); + + describe('Tests app status caching and onAppStatusChanged', function() { + beforeEach(function() { + mockDiscoveryService.getSinks.and.returnValue([sink1]); + mockDiscoveryService.getSinkCount.and.returnValue(1); + }); + + it('Known app status does not change during caching period', function() { + service.init(); + setUpGetAppInfoResponse(stoppedAppInfo); + expect(sink1.getAppStatus('YouTube')).toEqual(DialSinkAppStatus.UNKNOWN); + service.registerApp('YouTube'); + service.start(); + mockClock.tick(1); + expect(sink1.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.AVAILABLE); + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(1); + + // Make app unavailable + setUpGetAppInfoNotFoundError(); + service.doScan_(); + mockClock.tick(1); + // No new query and app is still available since cache is not expired. + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(sink1.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.AVAILABLE); + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(1); + }); + + it('Does not re-query app status when getAppInfo throws error', function() { + service.init(); + setUpGetAppInfoError(); + expect(sink1.getAppStatus('YouTube')).toEqual(DialSinkAppStatus.UNKNOWN); + service.registerApp('YouTube'); + service.start(); + mockClock.tick(1); + expect(sink1.getAppStatus('YouTube')).toEqual(DialSinkAppStatus.UNKNOWN); + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(0); + + service.doScan_(); + mockClock.tick(1); + // No new query + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(sink1.getAppStatus('YouTube')).toEqual(DialSinkAppStatus.UNKNOWN); + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(0); + }); + + it('Cache expires, re-query, different status', function() { + service.init(); + setUpGetAppInfoResponse(stoppedAppInfo); + expect(sink1.getAppStatus('YouTube')).toEqual(DialSinkAppStatus.UNKNOWN); + service.registerApp('YouTube'); + service.start(); + mockClock.tick(1); + expect(sink1.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.AVAILABLE); + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(1); + + // Make app unavailable + setUpGetAppInfoNotFoundError(); + // Make cache expire + mockClock.tick(AppDiscoveryService.CACHE_PERIOD_); + service.doScan_(); + mockClock.tick(1); + // new query and app becomes unavailable + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + expect(sink1.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.UNAVAILABLE); + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(2); + }); + + it('Cache expires, re-query, same status', function() { + service.init(); + setUpGetAppInfoResponse(stoppedAppInfo); + expect(sink1.getAppStatus('YouTube')).toEqual(DialSinkAppStatus.UNKNOWN); + service.registerApp('YouTube'); + service.start(); + mockClock.tick(1); + expect(sink1.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.AVAILABLE); + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(1); + + // Make cache expire + mockClock.tick(AppDiscoveryService.CACHE_PERIOD_); + service.doScan_(); + mockClock.tick(1); + // New query and app becomes unavailable + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + expect(sink1.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.AVAILABLE); + // Did not trigger app status changed event + expect(mockDiscoveryService.onAppStatusChanged.calls.count()).toBe(1); + }); + }); + + describe('Tests activity scanning', function() { + beforeEach(function() { + mockDiscoveryService.getSinks.and.returnValue([sink1, sink2, sink3]); + mockDiscoveryService.getSinkCount.and.returnValue(3); + mockDiscoveryService.getSinkById.and.returnValue(sink1); + + service.init(); + const route = Route.createRoute( + 'presentationId', 'providerName', sink1.getId(), 'source', true, + 'description', null); + const activity = new Activity(route, 'YouTube'); + activityRecords.add(activity); + expect(mockActivityCallbacks.onActivityAdded).toHaveBeenCalled(); + }); + + it('Activity is removed when app is no longer running', function() { + setUpGetAppInfoResponse(stoppedAppInfo); + service.start(); + mockClock.tick(1); + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(mockActivityCallbacks.onActivityRemoved.calls.count()).toBe(1); + expect(activityRecords.getActivityCount()).toBe(0); + }); + + it('Activity is not removed when app is still running', function() { + setUpGetAppInfoResponse(runningAppInfo); + service.start(); + mockClock.tick(1); + expect(mockDialClient.getAppInfo.calls.count()).toBe(1); + expect(mockActivityCallbacks.onActivityRemoved).not.toHaveBeenCalled(); + expect(activityRecords.getActivityCount()).toBe(1); + + // Trigger periodic rescan + mockClock.tick(AppDiscoveryService.CHECK_INTERVAL_MILLIS + 1); + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + expect(mockActivityCallbacks.onActivityRemoved).not.toHaveBeenCalled(); + expect(activityRecords.getActivityCount()).toBe(1); + + // No more periodic rescan. + service.stop(); + mockClock.tick(AppDiscoveryService.CHECK_INTERVAL_MILLIS + 1); + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + }); + + it('Activity scanning not duplicated with app scanning', function() { + setUpGetAppInfoResponse(stoppedAppInfo); + service.registerApp('YouTube'); + mockClock.tick(1); + // One for sink1 and one for sink2. Activity scanning does not issue a + // duplicated request. Both app status and activity status can be updated + // with the same response. + expect(mockDialClient.getAppInfo.calls.count()).toBe(2); + expect(sink1.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.AVAILABLE); + expect(sink2.getAppStatus('YouTube')) + .toEqual(DialSinkAppStatus.AVAILABLE); + expect(mockActivityCallbacks.onActivityRemoved.calls.count()).toBe(1); + expect(activityRecords.getActivityCount()).toBe(0); + }); + }); + + it('Persistent with data', function() { + mockDiscoveryService.getSinks.and.returnValue([]); + mockDiscoveryService.getSinkCount.and.returnValue(0); + const mockServiceToSuspend = new AppDiscoveryService( + mockDiscoveryService, new ActivityRecords(mockActivityCallbacks)); + mockServiceToSuspend.init(); + mockServiceToSuspend.registerApp('app1'); + mockServiceToSuspend.registerApp('app2'); + PersistentDataManager.suspendForTest(); + + const mockServiceToLoad = new AppDiscoveryService( + mockDiscoveryService, new ActivityRecords(mockActivityCallbacks)); + mockServiceToLoad.loadSavedData(); + expect(mockServiceToLoad.getRegisteredApps()).toEqual(['app1', 'app2']); + expect(mockServiceToLoad.getAppCount()).toEqual(2); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_client.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_client.js new file mode 100644 index 00000000000..52759a070fd --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_client.js @@ -0,0 +1,325 @@ +// 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. + +/** + * @fileoverview Client wrapper for interacting with DIAL devices and data + * structures for responses. + */ + +goog.module('mr.dial.Client'); +goog.module.declareLegacyNamespace(); + +const Assertions = goog.require('mr.Assertions'); +const Logger = goog.require('mr.Logger'); +const NetUtils = goog.require('mr.NetUtils'); +const XhrManager = goog.require('mr.XhrManager'); +const XhrUtils = goog.require('mr.XhrUtils'); + +/** + * Possible states of a DIAL application. + * @enum {string} + */ +const DialAppState = { + /** The app is running. */ + RUNNING: 'running', + /** The app is not running. */ + STOPPED: 'stopped', + /** The app can be installed. */ + INSTALLABLE: 'installable', + /** + * An error was encountered getting the state. + + */ + ERROR: 'error' +}; + + +/** + * Holds data parsed from a DIAL GET response. + */ +const AppInfo = class { + constructor() { + /** + * The application name. Mandatory. + * @type {string} + */ + this.name = 'unknown'; + + /** + * The reported state of the application. + * @type {DialAppState} + */ + this.state = DialAppState.ERROR; + + /** + * If the application's state is INSTALLABLE, then the URL where the app + * can be installed. + * @type {?string} + */ + this.installUrl = null; + + /** + * Whether the DELETE operation is supported. + * @type {boolean} + */ + this.allowStop = true; + + /** + * If the applications's state is RUNNING, a resource identifier for the + * running application. + * @type {?string} + */ + this.resource = null; + + /** + * Application-specific data included with the GET response that is not part + * of the official specifciations. + * @type {!Object<string, string>} + */ + this.extraData = {}; + } +}; + + +/** + * Indicates that the DIAL sink returned NOT_FOUND in response to a GET request. + */ +const AppInfoNotFoundError = class extends Error { + constructor() { + super(); + } +}; + + +/** + * Wrapper for a DIAL sink used for communicating with it. + */ +const Client = class { + /** + * @param {!mr.dial.Sink} sink + * @param {!XhrManager=} xhrManager Manager for all requests. + */ + constructor(sink, xhrManager = Client.getXhrManager_()) { + // NOTE(mfoltz,haibinlu): We do not assert if the sink supports DIAL, + // since combined discovery uses DialClient to check app info to see if a + // mDNS-discovered sink is also a DIAL sink. + Assertions.assert( + sink.getDialAppUrl(), 'Receiver must have a DIAL app URL set.'); + + /** @private @const {!mr.dial.Sink} */ + this.sink_ = sink; + + /** @private @const {!XhrManager} */ + this.xhrManager_ = xhrManager; + + /** @private @const {?Logger} */ + this.logger_ = Logger.getInstance('mr.dial.Client'); + } + + /** + * @param {!mr.dial.Sink} sink + * @return {!Client} + */ + static create(sink) { + return new Client(sink); + } + + /** + * Returns the default XhrManager, creating it if necessary. + * @return {!XhrManager} + * @private + */ + static getXhrManager_() { + if (!Client.xhrManager_) { + Client.xhrManager_ = new XhrManager( + /* maxRequests */ 10, + /* defaultTimeoutMillis */ 2000, + /* defaultNumAttempts */ 1); + } + return Client.xhrManager_; + } + + /** + * @param {string} state A string representing a DIAL application state. + * @return {DialAppState} The corresponding state or ERROR if the + * state is invalid. + * @private + */ + static parseDialAppState_(state) { + switch (state) { + case 'running': + return DialAppState.RUNNING; + case 'stopped': + return DialAppState.STOPPED; + default: + return DialAppState.ERROR; + } + } + + /** + * Launches an application on the sink. + * @param {string} appName Name of the DIAL application to launch. + * @param {string} postData Data to include in the HTTP POST request. + * @return {!Promise<void>} Fulfilled when the operation completes + * successfully. Rejected otherwise. + */ + launchApp(appName, postData) { + return this.xhrManager_ + .send( + this.getAppUrl_(appName), 'POST', postData, {timeoutMillis: 15000}) + .then(xhr => this.handleResponse_('launchApp', 'POST', xhr)); + } + + /** + * Stops a running application on the sink. + * @param {string} appName Name of the DIAL application to stop. + * @return {!Promise<void>} Fulfilled when the operation completes + * successfully. Rejected otherwise. + */ + stopApp(appName) { + return this.xhrManager_.send(this.getAppUrl_(appName), 'DELETE') + .then(xhr => this.handleResponse_('stopApp', 'DELETE', xhr)); + } + + /** + * Gets information about a running application on the sink. + * @param {string} appName Name of the DIAL application to get info from. + * @return {!Promise<!AppInfo>} Fulfilled with AppInfo. Rejected if the + * operation did not complete successfully. In the case of the sink + * returning NOT_FOUND for the request, AppInfoNotFoundError will be + * thrown. + */ + getAppInfo(appName) { + return this.xhrManager_ + .send(this.getAppUrl_(appName), 'GET', undefined, {numAttempts: 3}) + .then(xhr => this.handleGetAppInfoResponse_(appName, xhr)); + } + + /** + * Parses the response from a Xhr GET request. + * @param {string} appName App nam used in the request. + * @param {!XMLHttpRequest} xhr + * @return {!AppInfo} + * @private + */ + handleGetAppInfoResponse_(appName, xhr) { + XhrUtils.logRawXhr(this.logger_, 'GetAppInfo', 'GET', xhr); + if (!XhrUtils.isSuccess(xhr)) { + if (xhr.status == NetUtils.HttpStatus.NOT_FOUND) { + throw new AppInfoNotFoundError(); + } else { + throw new Error(`Response error: ${xhr.status}`); + } + } + + const xml = XhrUtils.parseXml(xhr.responseText); + if (!xml) { + this.logger_.info('Invalid or empty response'); + throw new Error('Invalid or empty response'); + } + + const service = xml.getElementsByTagName('service'); + if (!service || service.length != 1) { + this.logger_.info('Invalid GET response (invalid service)'); + throw new Error('Invalid GET response (invalid service)'); + } + const appInfo = new AppInfo(); + for (var i = 0, l = service[0].childNodes.length; i < l; i++) { + const node = service[0].childNodes[i]; + if (node.nodeName == 'state') { + appInfo.state = Client.parseDialAppState_(node.textContent); + } else if (node.nodeName == 'name') { + appInfo.name = node.textContent; + } else if (node.nodeName == 'link') { + appInfo.resource = node.getAttribute('href'); + } else if (node.nodeName == 'options') { + // The default value for allowStop is true per DIAL spec. + appInfo.allowStop = (node.getAttribute('allowStop') != 'false'); + } else { + appInfo.extraData[node.nodeName] = node.innerHTML; + } + } + + // Validate mandatory fields (name, state). + if (appInfo.name == 'unknown') { + this.logger_.info('GET response missing name value'); + throw new Error('GET response missing name value'); + } + + if (appInfo.name != appName) { + this.logger_.info('GET app name mismatch'); + throw new Error('GET app name mismatch'); + } + + if (appInfo.state == DialAppState.ERROR) { + this.logger_.info('GET response missing state value'); + throw new Error('GET response missing state value'); + } + + // Parse state. + const installable = /installable=(.+)/.exec(appInfo.state); + if (installable && installable[1]) { + appInfo.state = DialAppState.INSTALLABLE; + appInfo.installUrl = installable[1]; + } else if ( + appInfo.state == DialAppState.RUNNING || + appInfo.state == DialAppState.STOPPED) { + // Valid state. Continue. + } else { + this.logger_.info('GET response has invalid state value'); + throw new Error('GET response has invalid state value'); + } + + // Success! + return appInfo; + } + + /** + * Returns the URL used to communicate with a given DIAL application. + * @param {string} appName The name of the DIAL application. + * @return {string} The URL for the activity. + * @private + */ + getAppUrl_(appName) { + let appUrl = this.sink_.getDialAppUrl(); + if (appUrl.charAt(appUrl.length - 1) != '/') { + appUrl += '/'; + } + return appUrl + appName; + } + + /** + * Logs the given response and returns a Promise that resolves if it indicates + * success. + * @param {string} action Name of the operation that created the request. + * @param {string} method The HTTP method. + * @param {!XMLHttpRequest} xhr + * @return {!Promise<void>} Resolves if the response indicates success, + * rejected otherwise. + * @private + */ + handleResponse_(action, method, xhr) { + return new Promise((resolve, reject) => { + XhrUtils.logRawXhr(this.logger_, action, method, xhr); + if (XhrUtils.isSuccess(xhr)) { + resolve(); + } else { + reject(Error(xhr.statusText)); + } + }); + } +}; + + +/** + * Lazily instantiated and shared between DialClient instances. + * @private {?XhrManager} + */ +Client.xhrManager_ = null; + + +exports.AppInfo = AppInfo; +exports.AppInfoNotFoundError = AppInfoNotFoundError; +exports.Client = Client; +exports.DialAppState = DialAppState; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_client_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_client_test.js new file mode 100644 index 00000000000..0c1979d405f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_client_test.js @@ -0,0 +1,284 @@ +// 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. + +goog.module('mr.dial.ClientTest'); +goog.setTestOnly('mr.dial.ClientTest'); + +const DialClient = goog.require('mr.dial.Client'); +const DialSink = goog.require('mr.dial.Sink'); +const XhrUtils = goog.require('mr.XhrUtils'); + +describe('Dial Client Tests', function() { + let client; + let mockXhrManager; + let sink; + const appUrl = 'http://198.0.0.100/apps'; + + + beforeEach(function() { + sink = new DialSink('sink', 'sinkid'); + sink.setDialAppUrl(appUrl); + mockXhrManager = jasmine.createSpyObj('XhrManager', ['send']); + spyOn(XhrUtils, 'logRawXhr'); + client = new DialClient.Client(sink, mockXhrManager); + }); + + const setMockXhrResponse = function(xml) { + mockXhrManager.send.and.returnValue( + Promise.resolve({responseText: xml, status: 200})); + }; + + const setMockXhrErrorResponse = function() { + mockXhrManager.send.and.returnValue( + Promise.resolve({responseText: null, status: 403 /* Forbidden */})); + }; + + const setMockXhrNotFoundResponse = function() { + mockXhrManager.send.and.returnValue( + Promise.resolve({responseText: null, status: 404})); + }; + + const setMockXhrReject = function() { + mockXhrManager.send.and.returnValue( + Promise.reject(new Error('send failed'))); + }; + + // Suppress Jasmine warning about a spec with no expectations. + const noExpectations = function() { + expect(true).toBe(true); + }; + + describe('Tests launchApp', function() { + const expectLaunchAppFails = function(done) { + client.launchApp('YouTube', 'v=12345678').then(() => { + fail('launchApp unexpectedly succeeded.'); + }, done); + }; + + it('Resolves', done => { + setMockXhrResponse(''); + client.launchApp('YouTube', 'v=12345678').then(() => { + expect(mockXhrManager.send) + .toHaveBeenCalledWith( + appUrl + '/YouTube', 'POST', 'v=12345678', jasmine.any(Object)); + done(); + }); + }); + + it('Rejects on error response', done => { + setMockXhrErrorResponse(); + expectLaunchAppFails(done); + noExpectations(); + }); + + it('Rejects on send rejection', done => { + setMockXhrReject(); + expectLaunchAppFails(done); + noExpectations(); + }); + }); + + describe('Tests stopApp', function() { + const expectStopAppFails = function(done) { + client.stopApp('YouTube').then(() => { + fail('stopApp unexpectedly succeeded.'); + }, done); + }; + + it('Resolves', done => { + setMockXhrResponse(''); + client.stopApp('YouTube').then(() => { + expect(mockXhrManager.send) + .toHaveBeenCalledWith(appUrl + '/YouTube', 'DELETE'); + done(); + }); + }); + + it('Rejects on error response', done => { + setMockXhrErrorResponse(); + expectStopAppFails(done); + noExpectations(); + }); + + it('Rejects on send rejection', done => { + setMockXhrReject(); + expectStopAppFails(done); + noExpectations(); + }); + }); + + const expectMockSendGet = function() { + expect(mockXhrManager.send) + .toHaveBeenCalledWith( + 'http://198.0.0.100/apps/YouTube', 'GET', undefined, + jasmine.any(Object)); + }; + + const VALID_GET_RESPONSE_ = '<?xml version="1.0" encoding="UTF-8"?>' + + '<service xmlns="urn:dial-multiscreen-org:schemas:dial">' + + '<name>YouTube</name>' + + '<options allowStop="false"/>' + + '<state>running</state>' + + '<link rel="run" href="run"/>' + + '</service>'; + + const VALID_GET_RESPONSE_EXTRA_DATA_ = + '<?xml version="1.0" encoding="UTF-8"?>' + + '<service xmlns="urn:dial-multiscreen-org:schemas:dial">' + + '<name>YouTube</name>' + + '<state>running</state>' + + '<link rel="run" href="run"/>' + + '<port>8080</port>' + + '<additionalData>' + + '<screenId>e5n3112oskr42pg0td55b38nh4</screenId>' + + '<otherField>2</otherField>' + + '</additionalData>' + + '</service>'; + + const INVALID_GET_RESPONSE_NO_SERVICE_ = + '<?xml version="1.0" encoding="UTF-8"?>'; + + const INVALID_GET_RESPONSE_NO_STATE_ = + '<?xml version="1.0" encoding="UTF-8"?>' + + '<service xmlns="urn:dial-multiscreen-org:schemas:dial">' + + '<name>YouTube</name>' + + '<options allowStop="true"/>' + + '<link rel="run" href="run"/>' + + '</service>'; + + const INVALID_GET_RESPONSE_INVALID_STATE_ = + '<?xml version="1.0" encoding="UTF-8"?>' + + '<service xmlns="urn:dial-multiscreen-org:schemas:dial">' + + '<name>YouTube</name>' + + '<options allowStop="true"/>' + + '<state>xyzzy</state>' + + '<link rel="run" href="run"/>' + + '</service>'; + + const INVALID_GET_RESPONSE_INSTALLABLE_ = + '<?xml version="1.0" encoding="UTF-8"?>' + + '<service xmlns="urn:dial-multiscreen-org:schemas:dial">' + + '<name>YouTube</name>' + + '<options allowStop="true"/>' + + '<state>installable=http://play.google.com/youtube</state>' + + '<link rel="run" href="run"/>' + + '</service>'; + + const INVALID_GET_RESPONSE_NO_NAME_ = + '<?xml version="1.0" encoding="UTF-8"?>' + + '<service xmlns="urn:dial-multiscreen-org:schemas:dial">' + + '<options allowStop="true"/>' + + '<state>running</state>' + + '<link rel="run" href="run"/>' + + '</service>'; + + const INVALID_GET_RESPONSE_WRONG_APP_NAME_ = + '<?xml version="1.0" encoding="UTF-8"?>' + + '<service xmlns="urn:dial-multiscreen-org:schemas:dial">' + + '<name>WrongAppName</name>' + + '<options allowStop="true"/>' + + '<state>running</state>' + + '<link rel="run" href="run"/>' + + '</service>'; + + describe('Tests getAppInfo', function() { + it('Returns info from valid response', done => { + setMockXhrResponse(VALID_GET_RESPONSE_); + client.getAppInfo('YouTube').then(appInfo => { + expect(appInfo.name).toEqual('YouTube'); + expect(appInfo.state).toEqual('running'); + expect(appInfo.allowStop).toBe(false); + expect(appInfo.resource).toEqual('run'); + expectMockSendGet(); + done(); + }); + }); + + it('Returns info with extraData', done => { + setMockXhrResponse(VALID_GET_RESPONSE_EXTRA_DATA_); + client.getAppInfo('YouTube').then(appInfo => { + expect(appInfo.name).toEqual('YouTube'); + expect(appInfo.state).toEqual('running'); + expect(appInfo.allowStop).toBe(true); + expect(appInfo.resource).toEqual('run'); + expect(appInfo.extraData.port).toEqual('8080'); + expect(appInfo.extraData.additionalData) + .toEqual( + '<screenId xmlns="urn:dial-multiscreen-org:schemas:dial">' + + 'e5n3112oskr42pg0td55b38nh4</screenId>' + + '<otherField xmlns="urn:dial-multiscreen-org:schemas:dial">2' + + '</otherField>'); + expectMockSendGet(); + done(); + }); + }); + + const expectGetAppInfoFails = function(done) { + client.getAppInfo('YouTube').then( + _ => { + fail('getAppInfo unexpectedly succeeded.'); + }, + e => { + expectMockSendGet(); + done(); + }); + }; + + const testInvalidResponse = function(response, done) { + setMockXhrResponse(response); + expectGetAppInfoFails(done); + }; + + it('Rejects on invalid response 1', done => { + testInvalidResponse('blarg', done); + }); + + it('Rejects on invalid response 2', done => { + testInvalidResponse(INVALID_GET_RESPONSE_NO_SERVICE_, done); + }); + + it('Rejects on invalid response 3', done => { + testInvalidResponse(INVALID_GET_RESPONSE_NO_STATE_, done); + }); + + it('Rejects on invalid response 4', done => { + testInvalidResponse(INVALID_GET_RESPONSE_NO_NAME_, done); + }); + + it('Rejects on invalid response 5', done => { + testInvalidResponse(INVALID_GET_RESPONSE_INVALID_STATE_, done); + }); + + it('Rejects on invalid response 6', done => { + testInvalidResponse(INVALID_GET_RESPONSE_INSTALLABLE_, done); + }); + + it('Rejects on mismatched app name', done => { + testInvalidResponse(INVALID_GET_RESPONSE_WRONG_APP_NAME_, done); + }); + + it('Rejects on error response', done => { + setMockXhrErrorResponse(); + expectGetAppInfoFails(done); + }); + + it('Rejects on send rejection', done => { + setMockXhrReject(); + expectGetAppInfoFails(done); + }); + + it('Rejects with AppInfoNotFoundError', done => { + setMockXhrNotFoundResponse(); + client.getAppInfo('YouTube').then( + _ => { + fail('getAppInfo unexpectedly succeeded.'); + }, + e => { + expect(e instanceof DialClient.AppInfoNotFoundError).toBe(true); + expectMockSendGet(); + done(); + }); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider.js new file mode 100644 index 00000000000..f5c471a82a5 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider.js @@ -0,0 +1,474 @@ +// 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. + +goog.module('mr.DialProvider'); +goog.module.declareLegacyNamespace(); + +const Activity = goog.require('mr.dial.Activity'); +const ActivityRecords = goog.require('mr.dial.ActivityRecords'); +const AppDiscoveryService = goog.require('mr.dial.AppDiscoveryService'); +const Assertions = goog.require('mr.Assertions'); +const CancellablePromise = goog.require('mr.CancellablePromise'); +const DeviceCountsProvider = goog.require('mr.DeviceCountsProvider'); +const DialAnalytics = goog.require('mr.DialAnalytics'); +const DialClient = goog.require('mr.dial.Client'); +const DialPresentationUrl = goog.require('mr.dial.PresentationUrl'); +const DialSink = goog.require('mr.dial.Sink'); +const Logger = goog.require('mr.Logger'); +const MediaSourceUtils = goog.require('mr.MediaSourceUtils'); +const PresentationConnectionState = goog.require('mr.PresentationConnectionState'); +const Provider = goog.require('mr.Provider'); +const ProviderCallbacks = goog.require('mr.dial.ProviderCallbacks'); +const ProviderManagerCallbacks = goog.require('mr.ProviderManagerCallbacks'); +const ProviderName = goog.require('mr.ProviderName'); +const Route = goog.require('mr.Route'); +const RouteRequestError = goog.require('mr.RouteRequestError'); +const RouteRequestResultCode = goog.require('mr.RouteRequestResultCode'); +const SinkAppStatus = goog.require('mr.dial.SinkAppStatus'); +const SinkAvailability = goog.require('mr.SinkAvailability'); +const SinkDiscoveryService = goog.require('mr.dial.SinkDiscoveryService'); +const SinkList = goog.require('mr.SinkList'); +const SinkUtils = goog.require('mr.SinkUtils'); + +/** + * DIAL implementation of Media Route Provider. + * @implements {Provider} + * @implements {ProviderCallbacks} + * @implements {DeviceCountsProvider} + */ +const DialProvider = class { + /** + * @param {!ProviderManagerCallbacks} providerManagerCallbacks + * @param {!SinkDiscoveryService=} sinkDiscoveryService + * @param {!AppDiscoveryService=} appDiscoveryService + * @final + */ + constructor( + providerManagerCallbacks, sinkDiscoveryService = undefined, + appDiscoveryService = undefined) { + /** @private @const {!ProviderManagerCallbacks} */ + this.providerManagerCallbacks_ = providerManagerCallbacks; + + /** @private @const {!SinkDiscoveryService} */ + this.sinkDiscoveryService_ = + sinkDiscoveryService || new SinkDiscoveryService(this); + + /** @private @const {!ActivityRecords} */ + this.activityRecords_ = new ActivityRecords(this); + + /** @private {?AppDiscoveryService} */ + this.appDiscoveryService_ = appDiscoveryService || null; + + /** @private @const {?Logger} */ + this.logger_ = Logger.getInstance('mr.DialProvider'); + } + + /** + * @override + */ + getName() { + return ProviderName.DIAL; + } + + /** + * @override + */ + getDeviceCounts() { + return this.sinkDiscoveryService_.getDeviceCounts(); + } + + /** + * @override + */ + initialize(config) { + const sinkQueryEnabled = + (config && config.enable_dial_sink_query == false) ? false : true; + this.logger_.info('Dial sink query enabled: ' + sinkQueryEnabled + '...'); + + this.activityRecords_.init(); + this.sinkDiscoveryService_.init(); + + if (sinkQueryEnabled) { + this.appDiscoveryService_ = this.appDiscoveryService_ || + new AppDiscoveryService(this.sinkDiscoveryService_, + this.activityRecords_); + this.appDiscoveryService_.init(); + } else { + this.appDiscoveryService_ = null; + } + + this.maybeStartAppDiscovery_(); + } + + /** + * @override + */ + getAvailableSinks(sourceUrn) { + // Prevent SinkDiscoveryService to return cached available sinks. + if (!this.appDiscoveryService_) { + return SinkList.EMPTY; + } + + this.logger_.fine('GetAvailableSinks for ' + sourceUrn); + const dialMediaSource = DialPresentationUrl.create(sourceUrn); + return dialMediaSource ? + this.sinkDiscoveryService_.getSinksByAppName(dialMediaSource.appName) : + SinkList.EMPTY; + } + + /** + * @override + */ + startObservingMediaSinks(sourceUrn) { + if (!this.appDiscoveryService_) { + return; + } + + const dialMediaSource = DialPresentationUrl.create(sourceUrn); + if (dialMediaSource) { + this.appDiscoveryService_.registerApp(dialMediaSource.appName); + this.maybeStartAppDiscovery_(); + } + } + + /** + * @override + */ + stopObservingMediaSinks(sourceUrn) { + if (!this.appDiscoveryService_) { + return; + } + + const dialMediaSource = DialPresentationUrl.create(sourceUrn); + if (dialMediaSource) { + this.appDiscoveryService_.unregisterApp(dialMediaSource.appName); + this.maybeStopAppDiscovery_(); + } + } + + /** + * @override + */ + startObservingMediaRoutes(sourceUrn) { + this.maybeStartAppDiscovery_(); + } + + /** + * @override + */ + stopObservingMediaRoutes(sourceUrn) { + this.maybeStopAppDiscovery_(); + } + + /** + * @private + */ + maybeStopAppDiscovery_() { + if (!this.appDiscoveryService_) { + return; + } + + if (this.sinkDiscoveryService_.getSinkCount() == 0 || + (this.appDiscoveryService_.getAppCount() == 0 && + this.activityRecords_.getActivityCount() == 0)) { + this.appDiscoveryService_.stop(); + } + } + + /** + * @private + */ + maybeStartAppDiscovery_() { + if (!this.appDiscoveryService_) { + return; + } + + if (this.sinkDiscoveryService_.getSinkCount() > 0 && + (this.appDiscoveryService_.getAppCount() > 0 || + this.activityRecords_.getActivityCount() > 0)) { + this.appDiscoveryService_.start(); + } + } + + /** + * @override + */ + getSinkById(id) { + const dialSink = this.sinkDiscoveryService_.getSinkById(id); + return dialSink ? dialSink.getMrSink() : null; + } + + /** + * @override + */ + getRoutes() { + return this.activityRecords_.getRoutes(); + } + + /** + * @override + */ + createRoute( + sourceUrn, sinkId, presentationId, offTheRecord, timeoutMillis, + opt_origin, opt_tabId) { + const sink = this.sinkDiscoveryService_.getSinkById(sinkId); + if (!sink) { + DialAnalytics.recordCreateRoute( + DialAnalytics.DialRouteCreation.FAILED_NO_SINK); + return CancellablePromise.reject(Error('Unkown sink: ' + sinkId)); + } + SinkUtils.getInstance().recentLaunchedDevice = + new SinkUtils.DeviceData(sink.getModelName(), sink.getIpAddress()); + const dialMediaSource = DialPresentationUrl.create(sourceUrn); + if (!dialMediaSource) { + return CancellablePromise.reject(Error('No app name set.')); + } + const appName = dialMediaSource.appName; + const dialClient = this.newClient_(sink); + + return CancellablePromise.forPromise( + /** @type {!Promise<!Route>} */ + (dialClient.getAppInfo(appName) + .then(appInfo => { + if (appInfo.state == DialClient.DialAppState.RUNNING) { + return dialClient.stopApp(appName); + } + }) + .then(() => { + return dialClient.launchApp( + appName, dialMediaSource.launchParameter); + }) + .then(() => { + return this.addRoute( + sinkId, sourceUrn, true, appName, presentationId, + offTheRecord); + }) + .catch(err => { + DialAnalytics.recordCreateRoute( + DialAnalytics.DialRouteCreation.FAILED_LAUNCH_APP); + throw err; + }))); + } + + /** + * @override + */ + joinRoute( + sourceUrn, presentationId, offTheRecord, timeoutMillis, origin, tabId) { + return CancellablePromise.reject(Error('Not supported')); + } + + /** + * @override + */ + connectRouteByRouteId(sourceUrn, routeId, presentationId, origin, tabId) { + return CancellablePromise.reject(Error('Not supported')); + } + + /** + * @override + */ + detachRoute(routeId) {} + + /** + * @param {string} sinkId + * @param {?string} sourceUrn + * @param {boolean} isLocal + * @param {string} appName + * @param {string} presentationId + * @param {boolean} offTheRecord + * @return {!Route} The route that was just added. + */ + addRoute(sinkId, sourceUrn, isLocal, appName, presentationId, offTheRecord) { + DialAnalytics.recordCreateRoute( + DialAnalytics.DialRouteCreation.ROUTE_CREATED); + const route = Route.createRoute( + presentationId, this.getName(), sinkId, sourceUrn, isLocal, appName, + null); + route.offTheRecord = offTheRecord; + this.activityRecords_.add(new Activity(route, appName)); + return route; + } + + /** + * @override + */ + onSinkAdded(sink) { + this.providerManagerCallbacks_.onSinkAvailabilityUpdated( + this, SinkAvailability.PER_SOURCE); + if (this.appDiscoveryService_) { + this.maybeStartAppDiscovery_(); + this.appDiscoveryService_.scanSink(sink); + } + this.providerManagerCallbacks_.onSinksUpdated(); + SinkUtils.getInstance().recentDiscoveredDevice = + new SinkUtils.DeviceData(sink.getModelName(), sink.getIpAddress()); + } + + /** + * @override + */ + onSinksRemoved(sinks) { + if (this.sinkDiscoveryService_.getSinkCount() == 0) { + this.providerManagerCallbacks_.onSinkAvailabilityUpdated( + this, SinkAvailability.UNAVAILABLE); + } + this.maybeStopAppDiscovery_(); + sinks.forEach(sink => { + this.activityRecords_.removeBySinkId(sink.getId()); + }); + this.providerManagerCallbacks_.onSinksUpdated(); + } + + /** + * @override + */ + onSinkUpdated(sink) { + this.providerManagerCallbacks_.onSinksUpdated(); + } + + /** + * @override + */ + onActivityAdded(activity) { + this.maybeStartAppDiscovery_(); + this.providerManagerCallbacks_.onRouteAdded(this, activity.route); + } + + /** + * @override + */ + onActivityRemoved(activity) { + const route = activity.route; + if (route.isLocal) { + this.providerManagerCallbacks_.onPresentationConnectionStateChanged( + route.id, PresentationConnectionState.TERMINATED); + } + this.maybeStopAppDiscovery_(); + this.providerManagerCallbacks_.onRouteRemoved(this, route); + } + + /** + * @override + */ + onActivityUpdated(activity) { + this.providerManagerCallbacks_.onRouteUpdated(this, activity.route); + } + + /** + * @override + */ + terminateRoute(routeId) { + const activity = this.activityRecords_.getByRouteId(routeId); + if (!activity) { + return Promise.reject(new RouteRequestError( + RouteRequestResultCode.ROUTE_NOT_FOUND, + 'Route in DIAL provider not found for routeId ' + routeId)); + } + this.activityRecords_.removeByRouteId(routeId); + const sink = this.sinkDiscoveryService_.getSinkById(activity.route.sinkId); + if (!sink) { + return Promise.reject(new RouteRequestError( + RouteRequestResultCode.ROUTE_NOT_FOUND, + 'Sink in DIAL provider not found for sinkId ' + + activity.route.sinkId)); + } + return this.newClient_(sink).stopApp(activity.appName); + } + + /** + * @override + */ + getMirrorSettings(sinkId) { + throw new Error('Not implemented.'); + } + + /** + * @override + */ + getMirrorServiceName(sinkId) { + return null; + } + + /** + * @override + */ + onMirrorActivityUpdated(routeId) {} + + /** + * @override + */ + sendRouteMessage(routeId, message, opt_extraInfo) { + return Promise.reject(Error('DIAL sending messages is not supported')); + } + + /** + * @override + */ + sendRouteBinaryMessage(routeId, message) { + return Promise.reject(Error('DIAL sending messages is not supported')); + } + + /** + * @override + */ + canRoute(sourceUrn, sinkId) { + const sink = this.sinkDiscoveryService_.getSinkById(sinkId); + if (!sink) { + return false; + } + + if (MediaSourceUtils.isMirrorSource(sourceUrn)) { + return false; + } + + const dialMediaSource = DialPresentationUrl.create(sourceUrn); + if (!dialMediaSource) { + return false; + } + return sink.getAppStatus(dialMediaSource.appName) == + SinkAppStatus.AVAILABLE; + } + + /** + * @override + */ + canJoin(sourceUrn, presentationId, route) { + return false; + } + + /** + * @override + */ + searchSinks(sourceUrn, searchCriteria) { + // Not implemented. + return Assertions.rejectNotImplemented(); + } + + /** + * @override + */ + createMediaRouteController(routeId, controllerRequest, observer) { + // Not implemented. + return Assertions.rejectNotImplemented(); + } + + /** + * @override + */ + provideSinks(sinks) { + this.sinkDiscoveryService_.addSinks(sinks); + } + + /** + * @param {!DialSink} sink + * @return {!DialClient.Client} + * @private + */ + newClient_(sink) { + return new DialClient.Client(sink); + } +}; + +exports = DialProvider; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider_callbacks.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider_callbacks.js new file mode 100644 index 00000000000..2c7284922b0 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider_callbacks.js @@ -0,0 +1,65 @@ +// 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. + +/** + * @fileoverview DIAL provider callbacks used for other DIAL services to inform + * activity or sink updates. + */ + +goog.provide('mr.dial.ActivityCallbacks'); +goog.provide('mr.dial.ProviderCallbacks'); +goog.provide('mr.dial.SinkDiscoveryCallbacks'); + + + +/** + * @record + */ +mr.dial.ActivityCallbacks = class { + /** + * @param {!mr.dial.Activity } activity + */ + onActivityAdded(activity) {} + + /** + * @param {!mr.dial.Activity } activity + */ + onActivityRemoved(activity) {} + + /** + * @param {!mr.dial.Activity } activity + */ + onActivityUpdated(activity) {} +}; + + + +/** + * @record + */ +mr.dial.SinkDiscoveryCallbacks = class { + /** + * @param {!mr.dial.Sink} sink Sink that has been added. + */ + onSinkAdded(sink) {} + + /** + * @param {!Array.<!mr.dial.Sink>} sinks Sinks that have been removed. + */ + onSinksRemoved(sinks) {} + + /** + * @param {!mr.dial.Sink} sink Sink that has been updated. + */ + onSinkUpdated(sink) {} +}; + + + +/** + * @record + * @extends {mr.dial.ActivityCallbacks} + * @extends {mr.dial.SinkDiscoveryCallbacks} + */ +mr.dial.ProviderCallbacks = class {}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider_test.js new file mode 100644 index 00000000000..c82f8b9931e --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_provider_test.js @@ -0,0 +1,256 @@ +// 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. + +goog.module('mr.DialProviderTest'); +goog.setTestOnly('mr.DialProviderTest'); + +const Activity = goog.require('mr.dial.Activity'); +const DialClient = goog.require('mr.dial.Client'); +const DialProvider = goog.require('mr.DialProvider'); +const DialSink = goog.require('mr.dial.Sink'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); +const PresentationConnectionState = goog.require('mr.PresentationConnectionState'); +const Route = goog.require('mr.Route'); +const SinkAvailability = goog.require('mr.SinkAvailability'); + + +describe('DialProvider tests', function() { + let provider; + let mockPmCallbacks; + const pmCallbackMethods = [ + 'getRouteMessageEventTarget', 'getProviderFromRouteId', + 'onPresentationConnectionClosed', 'onPresentationConnectionStateChanged', + 'onRouteMessage', 'onRouteRemoved', 'onRouteAdded', 'onSinksUpdated', + 'onSinkAvailabilityUpdated' + ]; + let mockSinkDiscoveryService; + const sinkDiscoveryServiceMethods = + ['init', 'getSinkCount', 'init', 'getSinksByAppName', 'getSinkById']; + let mockAppDiscoveryService; + const appDiscoveryServiceMethods = [ + 'init', 'start', 'stop', 'registerApp', 'unregisterApp', 'getAppCount', + 'scanSink' + ]; + let mockDialClient; + + const appInfo = new DialClient.AppInfo(); + const youTubeUrl = 'dial:YouTube?postData=dj0xMjM='; + + beforeEach(function() { + mockPmCallbacks = + jasmine.createSpyObj('ProviderManagerCallbacks', pmCallbackMethods); + + mockSinkDiscoveryService = jasmine.createSpyObj( + 'SinkDiscoveryService', sinkDiscoveryServiceMethods); + mockAppDiscoveryService = + jasmine.createSpyObj('AppDiscoveryService', appDiscoveryServiceMethods); + + provider = new DialProvider( + mockPmCallbacks, mockSinkDiscoveryService, mockAppDiscoveryService); + provider.initialize({enable_dial_discovery: true}); + expect(mockAppDiscoveryService.init).toHaveBeenCalled(); + + const fakeDialSink = new DialSink('Fake DIAL sink', 'uniqueId'); + fakeDialSink.setDialAppUrl(youTubeUrl); + mockDialClient = jasmine.createSpyObj( + 'dialClient', ['getAppInfo', 'launchApp', 'stopApp']); + spyOn(DialProvider.prototype, 'newClient_').and.returnValue(mockDialClient); + mockSinkDiscoveryService.getSinkById.and.returnValue(fakeDialSink); + appInfo.name = 'YouTube'; + appInfo.state = DialClient.DialAppState.STOPPED; + mr.DialAnalytics.recordCreateRoute = jasmine.createSpy('recordCreateRoute'); + }); + + afterEach(function() { + PersistentDataManager.clear(); + }); + + describe('startObservingMediaSinks Test', function() { + it('Handles non-dial sink query', function() { + provider.startObservingMediaSinks('urn:not-dial:YouTube'); + expect(mockAppDiscoveryService.registerApp).not.toHaveBeenCalled(); + }); + + it('Handles valid dial sink query, no sinks', function() { + mockSinkDiscoveryService.getSinkCount.and.returnValue(0); + mockAppDiscoveryService.getAppCount.and.returnValue(1); + provider.startObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.registerApp) + .toHaveBeenCalledWith('YouTube'); + expect(mockAppDiscoveryService.start).not.toHaveBeenCalled(); + }); + + it('Handles valid dial sink query, at least one sink', function() { + mockSinkDiscoveryService.getSinkCount.and.returnValue(1); + mockAppDiscoveryService.getAppCount.and.returnValue(1); + provider.startObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.registerApp) + .toHaveBeenCalledWith('YouTube'); + expect(mockAppDiscoveryService.start).toHaveBeenCalled(); + }); + }); + + describe('stopObservingMediaSinks Test', function() { + it('Handles non-dial sink query', function() { + provider.stopObservingMediaSinks('urn:not-dial:YouTube'); + expect(mockAppDiscoveryService.unregisterApp).not.toHaveBeenCalled(); + }); + + it('Handles valid dial sink query', function() { + mockAppDiscoveryService.getAppCount.and.returnValue(0); + provider.stopObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.unregisterApp) + .toHaveBeenCalledWith('YouTube'); + }); + + it('Handles valid dial sink query, app query remains', function() { + mockAppDiscoveryService.getAppCount.and.returnValue(1); + provider.stopObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.unregisterApp) + .toHaveBeenCalledWith('YouTube'); + }); + }); + + describe('onPresentationConnectionStateChanged Test', function() { + it('Changes presentation state to terminated', function() { + const route = provider.addRoute( + 'sink1', youTubeUrl, true, 'app1', 'presentationId1'); + provider.onActivityRemoved(new Activity(route, 'app1')); + expect(mockPmCallbacks.onPresentationConnectionStateChanged) + .toHaveBeenCalledWith( + route.id, PresentationConnectionState.TERMINATED); + expect(mockPmCallbacks.onRouteRemoved).toHaveBeenCalled(); + }); + + it('Does not change state for non-local presentation', function() { + const route = provider.addRoute( + 'sink1', youTubeUrl, false, 'app1', 'presentationId1'); + provider.onActivityRemoved(new Activity(route, 'app1')); + expect(mockPmCallbacks.onPresentationConnectionStateChanged) + .not.toHaveBeenCalled(); + expect(mockPmCallbacks.onRouteRemoved).toHaveBeenCalled(); + }); + }); + + describe('createRoute Test', function() { + it('Creates a route', function(done) { + mockDialClient.getAppInfo.and.returnValue(Promise.resolve(appInfo)); + mockDialClient.launchApp.and.returnValue(Promise.resolve()); + provider.createRoute(youTubeUrl, 'sink1', 'presentationId1', false) + .promise.then( + route => { + expect(route.sinkId).toBe('sink1'); + expect(route.mediaSource).toBe(youTubeUrl); + expect(route.offTheRecord).toBe(false); + expect(mr.DialAnalytics.recordCreateRoute) + .toHaveBeenCalledWith( + mr.DialAnalytics.DialRouteCreation.ROUTE_CREATED); + done(); + }, + e => { + done.fail('Unexpected error: ' + e.message); + }); + }); + + it('Creates an off-the-record route', function(done) { + mockDialClient.getAppInfo.and.returnValue(Promise.resolve(appInfo)); + mockDialClient.launchApp.and.returnValue(Promise.resolve()); + provider.createRoute(youTubeUrl, 'sink1', 'presentationId1', true) + .promise.then( + route => { + expect(route.sinkId).toBe('sink1'); + expect(route.mediaSource).toBe(youTubeUrl); + expect(route.offTheRecord).toBe(true); + expect(mr.DialAnalytics.recordCreateRoute) + .toHaveBeenCalledWith( + mr.DialAnalytics.DialRouteCreation.ROUTE_CREATED); + done(); + }, + e => { + done.fail('Unexpected error: ' + e.message); + }); + }); + + it('Fails to create a route when get app info fails', function(done) { + mockDialClient.getAppInfo.and.returnValue(Promise.reject('fail')); + provider.createRoute(youTubeUrl, 'sink1', 'presentationId1', true) + .promise.then(done.fail, e => { + expect(mr.DialAnalytics.recordCreateRoute) + .toHaveBeenCalledWith( + mr.DialAnalytics.DialRouteCreation.FAILED_LAUNCH_APP); + done(); + }); + }); + + it('Fails to create a route when launch app fails', function(done) { + mockDialClient.getAppInfo.and.returnValue(Promise.resolve(appInfo)); + mockDialClient.launchApp.and.returnValue(Promise.reject('fail')); + provider.createRoute(youTubeUrl, 'sink1', 'presentationId1', true) + .promise.then(done.fail, e => { + expect(mr.DialAnalytics.recordCreateRoute) + .toHaveBeenCalledWith( + mr.DialAnalytics.DialRouteCreation.FAILED_LAUNCH_APP); + done(); + }); + }); + + it('Starts and stop app discovery', function() { + mockSinkDiscoveryService.getSinkCount.and.returnValue(1); + let appCount = 0; + mockAppDiscoveryService.getAppCount.and.callFake(() => appCount); + const route = Route.createRoute( + 'presentationId', 'providerName', 'sinkId', 'source', true, + 'description', null); + const activity = new Activity(route, 'YouTube'); + provider.activityRecords_.add(activity); + expect(mockAppDiscoveryService.start).toHaveBeenCalled(); + + appCount++; + provider.startObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.registerApp) + .toHaveBeenCalledWith('YouTube'); + + appCount--; + provider.stopObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.stop).not.toHaveBeenCalled(); + + provider.activityRecords_.removeByRouteId(route.id); + expect(mockAppDiscoveryService.stop).toHaveBeenCalled(); + }); + + it('Sets SinkAvailability to UNAVAILABLE if no more sinks', () => { + mockSinkDiscoveryService.getSinkCount.and.returnValue(0); + // Note: This should also work if an non-empty list if passed in. For + // simplicity, an empty list is used here. + provider.onSinksRemoved([]); + expect(mockPmCallbacks.onSinkAvailabilityUpdated) + .toHaveBeenCalledWith(provider, SinkAvailability.UNAVAILABLE); + }); + }); + + describe('Disables Dial sink query', function() { + beforeEach(function() { + PersistentDataManager.clear(); + provider.initialize({enable_dial_sink_query: false}); + expect(mockAppDiscoveryService.start).not.toHaveBeenCalled(); + }); + + it('Starting and stopping observing media sinks does nothing', function() { + provider.startObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.registerApp).not.toHaveBeenCalled(); + + provider.stopObservingMediaSinks(youTubeUrl); + expect(mockAppDiscoveryService.unregisterApp).not.toHaveBeenCalled(); + }); + + it('onSinkAdded does not start app discovery', function() { + const sink = new DialSink('s1', 'sink1'); + provider.onSinkAdded(sink); + expect(mockAppDiscoveryService.scanSink).not.toHaveBeenCalled(); + + const sinkList = provider.getAvailableSinks(); + expect(sinkList.sinks.length).toBe(0); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink.js new file mode 100644 index 00000000000..8cf0e1fe39d --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink.js @@ -0,0 +1,309 @@ +// 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. + +goog.module('mr.dial.Sink'); +goog.module.declareLegacyNamespace(); + +const Sink = goog.require('mr.Sink'); +const SinkAppStatus = goog.require('mr.dial.SinkAppStatus'); + + +/** + * A wrapper for Sink containing DIAL specific data. + */ +const DialSink = class { + /** + * @param {string} friendlyName + * @param {string} uniqueId + * @final + */ + constructor(friendlyName, uniqueId) { + /** @private {?string} */ + this.ipAddress_ = null; + + /** @private {?number} */ + this.port_ = null; + + /** @private {?string} */ + this.dialAppUrl_ = null; + + /** @private {?string} */ + this.deviceDescriptionUrl_ = null; + + /** @private {?string} */ + this.modelName_ = null; + + /** @private @const {!Sink} */ + this.mrSink_ = new Sink(uniqueId, friendlyName); + + /** + * Holds the status of applications that may be available on the sink. + * Keys are application Names. + * @private {!Object<string, SinkAppStatus>} + */ + this.appStatusMap_ = {}; + + /** + * Holds the timestamp when the status of applications was set. + * @private {!Object<string, number>} + */ + this.appStatusTimeStamp_ = {}; + + /** @private {boolean} */ + this.supportsAppAvailability_ = false; + } + + /** + * @return {!Sink} + */ + getMrSink() { + return this.mrSink_; + } + + /** + * @return {string} A human readable name for the sink. + */ + getFriendlyName() { + return this.mrSink_.friendlyName; + } + + /** + * @param {string} friendlyName + * @return {!mr.dial.Sink} This sink. + */ + setFriendlyName(friendlyName) { + this.mrSink_.friendlyName = friendlyName; + return this; + } + + /** + * @return {?string} sink model name if known. + */ + getModelName() { + return this.modelName_; + } + + /** + * @param {?string} modelName + * @return {!mr.dial.Sink} This sink. + */ + setModelName(modelName) { + this.modelName_ = modelName; + return this; + } + + /** + * @return {string} An identifier for this sink. + */ + getId() { + return this.mrSink_.id; + } + + /** + * @param {string} id + * @return {!mr.dial.Sink} This sink. + */ + setId(id) { + this.mrSink_.id = id; + return this; + } + + /** + * @return {boolean} Whether this sink supports queries for DIAL app + * availability. + */ + supportsAppAvailability() { + return this.supportsAppAvailability_; + } + + /** + * Sets whether this sink supports DIAL app availability queries. + * @param {boolean} availability + * @return {!mr.dial.Sink} This sink. + */ + setSupportsAppAvailability(availability) { + this.supportsAppAvailability_ = availability; + return this; + } + + /** + * Updates sink properties. + * Fields that can be updated: friendlyName, dialAppUrl_, + * deviceDescriptionUrl_, ipAddress_, port_. + * @param {!mr.dial.Sink} sink + * @return {boolean} Whether the update resulted in changes to the sink. + */ + update(sink) { + if (this.getId() != sink.getId()) { + return false; + } + + let updated = false; + + if (this.mrSink_.friendlyName != sink.mrSink_.friendlyName) { + this.mrSink_.friendlyName = sink.mrSink_.friendlyName; + updated = true; + } + + if (this.dialAppUrl_ != sink.dialAppUrl_) { + this.dialAppUrl_ = sink.dialAppUrl_; + updated = true; + } + + if (this.deviceDescriptionUrl_ != sink.deviceDescriptionUrl_) { + this.deviceDescriptionUrl_ = sink.deviceDescriptionUrl_; + updated = true; + } + + if (this.ipAddress_ != sink.ipAddress_) { + this.ipAddress_ = sink.ipAddress_; + updated = true; + } + + if (this.port_ != sink.port_) { + this.port_ = sink.port_; + updated = true; + } + + return updated; + } + + /** + * @return {?string} The IP address of the sink, if any. + */ + getIpAddress() { + return this.ipAddress_; + } + + /** + * @param {?string} ipAddress The sink IP address. + * @return {!mr.dial.Sink} This sink. + */ + setIpAddress(ipAddress) { + this.ipAddress_ = ipAddress; + return this; + } + + /** + * @return {?number} The port number of the secure channel service. + */ + getPort() { + return this.port_; + } + + /** + * @param {?number} port + * @return {!mr.dial.Sink} This sink. + */ + setPort(port) { + this.port_ = port; + return this; + } + + /** + * @return {?string} The DIAL application URL, if any. + */ + getDialAppUrl() { + return this.dialAppUrl_; + } + + /** + * @param {string} url The DIAL app URL. + * @return {!mr.dial.Sink} This sink. + */ + setDialAppUrl(url) { + this.dialAppUrl_ = url; + return this; + } + + /** + * @return {?string} The DIAL device description URL, if any. + */ + getDeviceDescriptionUrl() { + return this.deviceDescriptionUrl_; + } + + /** + * @param {string} url The DIAL device description URL. + * @return {!mr.dial.Sink} This sink. + */ + setDeviceDescriptionUrl(url) { + this.deviceDescriptionUrl_ = url; + return this; + } + + /** + * Gets the availability of an application. + * @param {string} appName + * @return {SinkAppStatus} The status of the application, or null if it was + * not set. + */ + getAppStatus(appName) { + return this.appStatusMap_[appName] || SinkAppStatus.UNKNOWN; + } + + /** + * Gets the time stamp of the availability of an application was set. + * @param {string} appName + * @return {?number} the number of milliseconds between midnight, January 1, + * 1970 and the current time, or null if availability was not set. + */ + getAppStatusTimeStamp(appName) { + return this.appStatusTimeStamp_[appName] || null; + } + + /** + * Sets the availability of an application. + * @param {string} appName + * @param {SinkAppStatus} status + * @return {!mr.dial.Sink} This sink. + */ + setAppStatus(appName, status) { + this.appStatusMap_[appName] = status; + this.appStatusTimeStamp_[appName] = Date.now(); + return this; + } + + /** + * Clears all app status from the sink. + * @return {!mr.dial.Sink} This sink. + */ + clearAppStatus() { + this.appStatusMap_ = {}; + this.appStatusTimeStamp_ = {}; + return this; + } + + /** + * @return {string} String suitable for fine logging. + */ + toDebugString() { + return 'name = ' + this.mrSink_.friendlyName + + (this.ipAddress_ ? ', ip = ' + this.ipAddress_ : '') + + (this.modelName_ ? ', model = ' + this.modelName_ : '') + + ', apps = ' + JSON.stringify(this.appStatusMap_); + } + + /** + * Creates a new sink and copies the fields of the input sink to this. + * @param {!Object<string, *>} sink The object containing data fields. + * @return {!mr.dial.Sink} A newly created sink. + */ + static createFrom(sink) { + const newSink = new DialSink(sink.mrSink_.friendlyName, ''); + newSink.mrSink_.id = sink.mrSink_.id; // Override the sink ID. + newSink.ipAddress_ = sink.ipAddress_; + newSink.port_ = sink.port_; + newSink.dialAppUrl_ = sink.dialAppUrl_; + newSink.deviceDescriptionUrl_ = sink.deviceDescriptionUrl_; + newSink.modelName_ = sink.modelName_; + newSink.appStatusMap_ = sink.appStatusMap_; + newSink.appStatusTimeStamp_ = sink.appStatusTimeStamp_; + newSink.supportsAppAvailability_ = sink.supportsAppAvailability_; + return newSink; + } +}; + + +exports = DialSink; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_discovery_service.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_discovery_service.js new file mode 100644 index 00000000000..93d062e3519 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_discovery_service.js @@ -0,0 +1,334 @@ +// 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. + +goog.module('mr.dial.SinkDiscoveryService'); +goog.module.declareLegacyNamespace(); + +const DeviceCounts = goog.require('mr.DeviceCounts'); +const DeviceCountsProvider = goog.require('mr.DeviceCountsProvider'); +const DialAnalytics = goog.require('mr.DialAnalytics'); +const DialSink = goog.require('mr.dial.Sink'); +const Logger = goog.require('mr.Logger'); +const PersistentData = goog.require('mr.PersistentData'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); +const SinkAppStatus = goog.require('mr.dial.SinkAppStatus'); +const SinkDiscoveryCallbacks = goog.require('mr.dial.SinkDiscoveryCallbacks'); +const SinkList = goog.require('mr.SinkList'); + + +/** + * Implements local discovery using DIAL. + * DIAL specification: + * http://www.dial-multiscreen.org/dial-protocol-specification + * @implements {PersistentData} + * @implements {DeviceCountsProvider} + */ +class SinkDiscoveryService { + /** + * @param {!SinkDiscoveryCallbacks} sinkCallBacks + * @final + */ + constructor(sinkCallBacks) { + /** + * @private @const {!SinkDiscoveryCallbacks} + */ + this.sinkCallBacks_ = sinkCallBacks; + + /** + * @private @const {?Logger} + */ + this.logger_ = Logger.getInstance('mr.dial.SinkDiscoveryService'); + + /** + * The current set of *accessible* receivers, indexed by id. + * @private @const {!Map<string, !DialSink>} + */ + this.sinkMap_ = new Map(); + + /** + * The most recent snapshot of device counts. + * Updated when a DIAL onDeviceList or onError event is received. + * Part of PersistentData. + * @private {!DeviceCounts} + */ + this.deviceCounts_ = {availableDeviceCount: 0, knownDeviceCount: 0}; + + /** + * The last time device counts were recorded in DialAnalytics. + * Persistent data. + * @private {number} + */ + this.deviceCountMetricsRecordTime_ = 0; + } + + /** + * Initializes the service. Must be called before any other methods. + */ + init() { + PersistentDataManager.register(this); + } + + /** + * Add |sinks| to sink map. Remove outdated sinks that are in sink map but not + * in |sinks|. + * @param {!Array<!mojo.Sink>} sinks list of sinks discovered by Media Router. + */ + addSinks(sinks) { + this.logger_.info('addSinks returned ' + sinks.length + ' sinks'); + this.logger_.fine(() => '....the list is: ' + JSON.stringify(sinks)); + + const oldSinkIds = new Set(this.sinkMap_.keys()); + sinks.forEach(mojoSink => { + const dialSink = SinkDiscoveryService.convertSink_(mojoSink); + this.mayAddSink_(dialSink); + oldSinkIds.delete(dialSink.getId()); + }); + + let removedSinks = []; + oldSinkIds.forEach(sinkId => { + const sink = this.sinkMap_.get(sinkId); + removedSinks.push(sink); + this.sinkMap_.delete(sinkId); + }); + + if (removedSinks.length > 0) { + this.sinkCallBacks_.onSinksRemoved(removedSinks); + } + + // Record device count for feedback. + const sinkCount = this.getSinkCount(); + this.deviceCounts_ = { + availableDeviceCount: sinkCount, + knownDeviceCount: sinkCount + }; + } + + /** + * Updates deviceCounts_ with the given counts, and reports to analytics if + * applicable. + * @param {number} availableDeviceCount + * @param {number} knownDeviceCount + * @private + */ + recordDeviceCounts_(availableDeviceCount, knownDeviceCount) { + this.deviceCounts_ = { + availableDeviceCount: availableDeviceCount, + knownDeviceCount: knownDeviceCount + }; + if (Date.now() - this.deviceCountMetricsRecordTime_ < + SinkDiscoveryService.DEVICE_COUNT_METRIC_THRESHOLD_MS_) { + return; + } + DialAnalytics.recordDeviceCounts(this.deviceCounts_); + this.deviceCountMetricsRecordTime_ = Date.now(); + } + + /** + * Adds or updates an existing sink with the given sink. + * @param {!DialSink} sink The new or updated sink. + * @private + */ + mayAddSink_(sink) { + this.logger_.fine('mayAddSink, id = ' + sink.getId()); + const sinkToUpdate = this.sinkMap_.get(sink.getId()); + if (sinkToUpdate) { + if (sinkToUpdate.update(sink)) { + this.logger_.fine('Updated sink ' + sinkToUpdate.getId()); + this.sinkCallBacks_.onSinkUpdated(sinkToUpdate); + } + } else { + this.logger_.fine( + () => `Adding new sink ${sink.getId()}: ${sink.toDebugString()}`); + this.sinkMap_.set(sink.getId(), sink); + this.sinkCallBacks_.onSinkAdded(sink); + } + } + + /** + * Converts a mojo.Sink to a DialSink. + * @param {!mojo.Sink} mojoSink returned by Media Router at browser side. + * @return {!DialSink} DIAL sink. + * @private + */ + static convertSink_(mojoSink) { + + const uniqueId = mojoSink.sink_id; + const extraData = mojoSink.extra_data.dial_media_sink; + const isDiscoveryOnly = + SinkDiscoveryService.isDiscoveryOnly_(extraData.model_name); + + const ip_address = extraData.ip_address.address_bytes ? + extraData.ip_address.address_bytes.join('.') : + extraData.ip_address.address.join('.'); + return new DialSink(mojoSink.name, uniqueId) + .setIpAddress(ip_address) + .setDialAppUrl(extraData.app_url.url) + .setModelName(extraData.model_name) + .setSupportsAppAvailability(!isDiscoveryOnly); + } + + /** + * Returns true if DIAL (SSDP) was only used to discover this sink, and it is + * not expected to support other DIAL features (app discovery, activity + * discovery, etc.) + * @param {string} modelName + * @return {boolean} + * @private + */ + static isDiscoveryOnly_(modelName) { + return SinkDiscoveryService.DISCOVERY_ONLY_RE_.test(modelName); + } + + /** + * Returns the sink with the given ID, or null if not found. + * @param {string} sinkId + * @return {?DialSink} + */ + getSinkById(sinkId) { + return this.sinkMap_.get(sinkId) || null; + } + + /** + * Returns sinks that report availability of the given app name. + * @param {string} appName + * @return {!SinkList} + */ + getSinksByAppName(appName) { + const sinks = []; + this.sinkMap_.forEach(dialSink => { + if (dialSink.getAppStatus(appName) == SinkAppStatus.AVAILABLE) + sinks.push(dialSink.getMrSink()); + }); + return new SinkList( + sinks, SinkDiscoveryService.APP_ORIGIN_WHITELIST_[appName]); + } + + /** + * Returns current sinks. + * @return {!Array<!DialSink>} + */ + getSinks() { + return Array.from(this.sinkMap_.values()); + } + + /** + * @override + */ + getDeviceCounts() { + return this.deviceCounts_; + } + + /** + * @return {number} + */ + getSinkCount() { + return this.sinkMap_.size; + } + + /** + * Invoked when the app status of a sink changes. + * @param {string} appName + * @param {!DialSink} sink The sink whose status changed. + */ + onAppStatusChanged(appName, sink) { + this.sinkCallBacks_.onSinkUpdated(sink); + } + + /** + * @override + */ + getStorageKey() { + return 'dial.DialSinkDiscoveryService'; + } + + /** + * @override + */ + getData() { + return [ + new SinkDiscoveryService.PersistentData_( + Array.from(this.sinkMap_), this.deviceCounts_), + {'deviceCountMetricsRecordTime': this.deviceCountMetricsRecordTime_} + ]; + } + + /** + * @override + */ + loadSavedData() { + const tempData = + /** @type {?SinkDiscoveryService.PersistentData_} */ ( + PersistentDataManager.getTemporaryData(this)); + if (tempData) { + for (const entry of tempData.sinks) { + this.sinkMap_.set(entry[0], DialSink.createFrom(entry[1])); + } + this.deviceCounts_ = tempData.deviceCounts; + } + + const permanentData = PersistentDataManager.getPersistentData(this); + if (permanentData) { + this.deviceCountMetricsRecordTime_ = + permanentData['deviceCountMetricsRecordTime']; + } + } +} + + +/** + * @private @const {!Object<string, !Array<string>>} + */ +SinkDiscoveryService.APP_ORIGIN_WHITELIST_ = { + 'YouTube': [ + 'https://tv.youtube.com', 'https://tv-green-qa.youtube.com', + 'https://tv-release-qa.youtube.com', 'https://web-green-qa.youtube.com', + 'https://web-release-qa.youtube.com', 'https://www.youtube.com' + ], + 'Netflix': ['https://www.netflix.com'], + 'Pandora': ['https://www.pandora.com'], + 'Radio': ['https://www.pandora.com'], + 'Hulu': ['https://www.hulu.com'], + 'Vimeo': ['https://www.vimeo.com'], + 'Dailymotion': ['https://www.dailymotion.com'], + 'com.dailymotion': ['https://www.dailymotion.com'], +}; + + +/** + * Matches DIAL model names that only support discovery. + + * @private @const {!RegExp} + */ +SinkDiscoveryService.DISCOVERY_ONLY_RE_ = + new RegExp('Eureka Dongle|Chromecast Audio|Chromecast Ultra', 'i'); + +/** + * How long to wait between device counts metrics are recorded. Set to 1 hour. + * @private @const {number} + */ +SinkDiscoveryService.DEVICE_COUNT_METRIC_THRESHOLD_MS_ = 60 * 60 * 1000; + + +/** + * @private + */ +SinkDiscoveryService.PersistentData_ = class { + /** + * @param {!Array} sinks + * @param {!DeviceCounts} deviceCounts + */ + constructor(sinks, deviceCounts) { + /** + * @const {!Array} + */ + this.sinks = sinks; + + /** + * @const {!DeviceCounts} + */ + this.deviceCounts = deviceCounts; + } +}; + +exports = SinkDiscoveryService; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_discovery_service_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_discovery_service_test.js new file mode 100644 index 00000000000..1aad198346c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_discovery_service_test.js @@ -0,0 +1,163 @@ +// 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. + +goog.module('mr.dial.SinkDiscoveryServiceTest'); +goog.setTestOnly('mr.dial.SinkDiscoveryServiceTest'); + +const DialAnalytics = goog.require('mr.DialAnalytics'); +const PersistentDataManager = goog.require('mr.PersistentDataManager'); +const SinkAppStatus = goog.require('mr.dial.SinkAppStatus'); +const SinkDiscoveryService = goog.require('mr.dial.SinkDiscoveryService'); +const UnitTestUtils = goog.require('mr.UnitTestUtils'); + +describe('DIAL SinkDiscoveryService Tests', function() { + let service; + let mockClock; + let mockSinkCallbacks; + + beforeEach(function() { + mockClock = UnitTestUtils.useMockClockAndPromises(); + mockSinkCallbacks = jasmine.createSpyObj( + 'SinkCallbacks', ['onSinkAdded', 'onSinksRemoved', 'onSinkUpdated']); + + chrome.metricsPrivate = { + recordTime: jasmine.createSpy('recordTime'), + recordMediumTime: jasmine.createSpy('recordMediumTime'), + recordLongTime: jasmine.createSpy('recordLongTime'), + recordUserAction: jasmine.createSpy('recordUserAction') + }; + + service = new SinkDiscoveryService(mockSinkCallbacks); + spyOn(DialAnalytics, 'recordDeviceCounts'); + }); + + afterEach(function() { + UnitTestUtils.restoreRealClockAndPromises(); + PersistentDataManager.clear(); + }); + + + /** + * Creates mojo sink instances. + * @param {number} numSinks The number of mojo sinks to create. + * @return {!Array<!mojo.Sink>} The mojo sinks. + */ + function createMojoSinks(numSinks) { + const mojoSinks = []; + for (var i = 1; i <= numSinks; i++) { + const dialMediaSink = { + ip_address: {address_bytes: [127, 0, 0, i]}, + model_name: 'Eureka Dongle', + app_url: {url: 'http://127.0.0.' + i + ':8008/apps'} + }; + + mojoSinks.push({ + sink_id: 'sinkId ' + i, + name: 'TV ' + i, + extra_data: {dial_media_sink: dialMediaSink} + }); + } + return mojoSinks; + } + + describe('addSinks tests', function() { + beforeEach(function() { + service.init(); + }); + + it('add mojo sinks to sink map', function() { + expect(service.getSinks().length).toBe(0); + const mojoSinks = createMojoSinks(1); + service.addSinks(mojoSinks); + + // sinks were added + const actualSinks = service.getSinks(); + expect(actualSinks.length).toBe(1); + + const actualSink = actualSinks[0]; + const mojoSink = mojoSinks[0]; + const extraData = mojoSink.extra_data.dial_media_sink; + expect(actualSink.getFriendlyName()).toEqual(mojoSink.name); + expect(actualSink.getIpAddress()) + .toEqual(extraData.ip_address.address_bytes.join('.')); + expect(actualSink.getDialAppUrl()).toEqual(extraData.app_url.url); + expect(actualSink.getModelName()).toEqual(extraData.model_name); + expect(actualSink.supportsAppAvailability()).toEqual(false); + + // add-sink-events were fired. + expect(mockSinkCallbacks.onSinkAdded.calls.count()).toBe(1); + expect(mockSinkCallbacks.onSinksRemoved.calls.count()).toBe(0); + }); + + it('remove outdated sinks', function() { + expect(service.getSinks().length).toBe(0); + const mojoSinks = createMojoSinks(3); + // First round discover sink 1, 2, 3 + service.addSinks(mojoSinks); + + // Second round discover sink 1 + const mojoSinks2 = createMojoSinks(1); + service.addSinks(mojoSinks2); + expect(mockSinkCallbacks.onSinkAdded.calls.count()).toBe(3); + + // 2 devices were removed + expect(mockSinkCallbacks.onSinksRemoved.calls.count()).toBe(1); + const sinks = mockSinkCallbacks.onSinksRemoved.calls.argsFor(0)[0]; + expect(sinks.length).toBe(2); + expect(sinks[0].getFriendlyName()).toEqual(mojoSinks[1].name); + expect(sinks[1].getFriendlyName()).toEqual(mojoSinks[2].name); + + expect(mockSinkCallbacks.onSinkUpdated.calls.count()).toBe(0); + }); + + it('Gets sinks by app name', function() { + const mojoSinks = createMojoSinks(3); + service.addSinks(mojoSinks); + service.getSinkById(mojoSinks[0].sink_id) + .setAppStatus('YouTube', SinkAppStatus.AVAILABLE); + service.getSinkById(mojoSinks[1].sink_id) + .setAppStatus('Netflix', SinkAppStatus.AVAILABLE); + service.getSinkById(mojoSinks[1].sink_id) + .setAppStatus('YouTube', SinkAppStatus.AVAILABLE); + service.getSinkById(mojoSinks[2].sink_id) + .setAppStatus('Pandora', SinkAppStatus.UNAVAILABLE); + expect(service.getSinksByAppName('YouTube').sinks.length).toBe(2); + expect(service.getSinksByAppName('Netflix').sinks.length).toBe(1); + expect(service.getSinksByAppName('Netflix').sinks[0].id) + .toEqual(mojoSinks[1].sink_id); + expect(service.getSinksByAppName('Pandora').sinks.length).toBe(0); + }); + + }); + + it('Saves PersistentData without any data', function() { + service.init(); + expect(service.getSinks()).toEqual([]); + PersistentDataManager.suspendForTest(); + service = new SinkDiscoveryService(mockSinkCallbacks); + service.loadSavedData(); + expect(service.getSinks()).toEqual([]); + }); + + it('Saves PersistentData with data', function() { + service.init(); + service.addSinks(createMojoSinks(3)); + mockClock.tick(1); + const sinks = service.getSinks(); + expect(sinks.length).toBe(3); + const expectedDeviceCounts = {availableDeviceCount: 3, knownDeviceCount: 3}; + expect(service.getDeviceCounts()).toEqual(expectedDeviceCounts); + + PersistentDataManager.suspendForTest(); + service = new SinkDiscoveryService(mockSinkCallbacks); + service.loadSavedData(); + const restoredSinks = service.getSinks(); + expect(restoredSinks.length).toBe(3); + expect(service.getDeviceCounts()).toEqual(expectedDeviceCounts); + for (let index = 0; index < 3; index++) { + expect(sinks[0].getId()).toEqual(restoredSinks[0].getId()); + } + }); + +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_test.js new file mode 100644 index 00000000000..03a467550bd --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/dial_sink_test.js @@ -0,0 +1,127 @@ +// 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. + +goog.module('mr.dial.SinkTest'); +goog.setTestOnly('mr.dial.SinkTest'); + +const DialSink = goog.require('mr.dial.Sink'); +const MockClock = goog.require('mr.MockClock'); +const Sink = goog.require('mr.Sink'); +const SinkAppStatus = goog.require('mr.dial.SinkAppStatus'); + +describe('DIAL Sink Tests', function() { + let mockClock; + + beforeEach(function() { + mockClock = new MockClock(true); + }); + + afterEach(function() { + mockClock.uninstall(); + }); + + it('Gets and sets fields', function() { + const sink = new DialSink('name', 'id1'); + expect(sink.getMrSink()).toEqual(new Sink('id1', 'name')); + + expect(sink.getId()).toEqual('id1'); + sink.setId('id2'); + expect(sink.getId()).toEqual('id2'); + + expect(sink.getIpAddress()).toBeNull(); + sink.setIpAddress('192.168.111.1'); + expect(sink.getIpAddress()).toEqual('192.168.111.1'); + + expect(sink.getDialAppUrl()).toBeNull(); + sink.setDialAppUrl('http://192.168.111.1/apps'); + expect(sink.getDialAppUrl()).toEqual('http://192.168.111.1/apps'); + + expect(sink.getDeviceDescriptionUrl()).toBeNull(); + sink.setDeviceDescriptionUrl('http://192.168.111.1/desc'); + expect(sink.getDeviceDescriptionUrl()).toEqual('http://192.168.111.1/desc'); + + expect(sink.getModelName()).toBeNull(); + sink.setModelName('chromecast'); + expect(sink.getModelName()).toEqual('chromecast'); + + expect(sink.getFriendlyName()).toEqual('name'); + sink.setFriendlyName('newname'); + expect(sink.getFriendlyName()).toEqual('newname'); + + expect(sink.getPort()).toEqual(null); + sink.setPort(8009); + expect(sink.getPort()).toEqual(8009); + }); + + it('Gets and sets sink app status', function() { + const sink = new DialSink('name', 'uniqueId'); + expect(sink.getAppStatus('youtube')).toBe(SinkAppStatus.UNKNOWN); + expect(sink.getAppStatusTimeStamp('youtube')).toBe(null); + + mockClock.tick(10); + const now1 = Date.now(); + sink.setAppStatus('youtube', SinkAppStatus.AVAILABLE); + mockClock.tick(10); + const now2 = Date.now(); + sink.setAppStatus('app2', SinkAppStatus.UNAVAILABLE); + expect(sink.getAppStatus('youtube')).toBe(SinkAppStatus.AVAILABLE); + expect(sink.getAppStatusTimeStamp('youtube')).toBe(now1); + expect(sink.getAppStatus('app2')).toBe(SinkAppStatus.UNAVAILABLE); + expect(sink.getAppStatusTimeStamp('app2')).toBe(now2); + + sink.clearAppStatus(); + expect(sink.getAppStatus('youtube')).toBe(SinkAppStatus.UNKNOWN); + expect(sink.getAppStatusTimeStamp('youtube')).toBe(null); + expect(sink.getAppStatus('app2')).toBe(SinkAppStatus.UNKNOWN); + expect(sink.getAppStatusTimeStamp('app2')).toBe(null); + }); + + it('Updates sink from another sink', function() { + const sink = new DialSink('name', 'uniqueId') + .setDialAppUrl('http://192.168.111.1/apps') + .setPort(8009) + .setDeviceDescriptionUrl('http://192.168.111.1/desc'); + + let updatedSink = new DialSink('name2', 'uniqueId'); + expect(sink.update(updatedSink)).toBe(true); + expect(sink.getFriendlyName()).toEqual('name2'); + + updatedSink = new DialSink('name', 'uniqueId') + .setDialAppUrl('http://192.168.111.1/apps/app2'); + expect(sink.update(updatedSink)).toBe(true); + expect(sink.getDialAppUrl()).toEqual('http://192.168.111.1/apps/app2'); + + updatedSink = new DialSink('name', 'uniqueId').setId('id2'); + expect(sink.update(updatedSink)).toBe(false); + }); + + it('Updates ip address', function() { + const sink = new DialSink('name', 'uniqueId').setIpAddress('192.168.111.1'); + const updatedSink = + new DialSink('name', 'uniqueId').setIpAddress('192.168.111.2'); + expect(sink.update(updatedSink)).toBe(true); + expect(sink.getIpAddress()).toEqual('192.168.111.2'); + }); + + it('Updates device description url', function() { + const sink = new DialSink('name', 'uniqueId') + .setDeviceDescriptionUrl('http://192.168.111.1/desc'); + const updatedSink = + new DialSink('name', 'uniqueId') + .setDeviceDescriptionUrl('http://192.168.111.2/desc'); + expect(sink.update(updatedSink)).toBe(true); + expect(sink.getDeviceDescriptionUrl()).toEqual('http://192.168.111.2/desc'); + }); + + it('Creates sink from an Object', function() { + const sink = new DialSink('name', 'uniqueId') + .setDialAppUrl('http://192.168.111.1/apps') + .setPort(8009) + .setDeviceDescriptionUrl('http://192.168.111.1/desc') + .setAppStatus('youtube', SinkAppStatus.AVAILABLE) + .setIpAddress('192.168.111.1') + .setModelName('chromecast'); + expect(DialSink.createFrom(sink)).toEqual(sink); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/presentation_url.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/presentation_url.js new file mode 100644 index 00000000000..203b40e134f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/presentation_url.js @@ -0,0 +1,146 @@ +// 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. + +goog.module('mr.dial.PresentationUrl'); + +const Logger = goog.require('mr.Logger'); +const base64 = goog.require('mr.base64'); + + +/** + * Represents a DIAL media source containing information specific to a DIAL + * launch. + */ +const PresentationUrl = class { + /** + * @param {string} appName The DIAL application name. + * @param {string=} launchParameter DIAL application launch parameter. + */ + constructor(appName, launchParameter = '') { + /** @const {string} */ + this.appName = appName; + /** @const {string} */ + this.launchParameter = launchParameter; + } + + /** + * Generates a DIAL Presentation URL using given parameters. + * @param {string} dialAppName Name of the DIAL app. + * @param {?string} dialPostData base-64 encoded string of the data for the + * DIAL launch. + * @return {string} + */ + static getPresentationUrlAsString(dialAppName, dialPostData) { + const url = new URL('dial:' + dialAppName); + if (dialPostData) { + url.searchParams.set('postData', dialPostData); + } + return url.toString(); + } + + /** + * Constructs a DIAL media source from a URL. The URL can take on the new + * format (with dial: protocol) or the old format (with https: protocol). + * @param {string} urlString The media source URL. + * @return {?PresentationUrl} A DIAL media source if the parse was + * successful, null otherwise. + */ + static create(urlString) { + let url; + try { + url = new URL(urlString); + } catch (err) { + PresentationUrl.logger_.info('Invalid URL: ' + urlString); + return null; + } + switch (url.protocol) { + case 'dial:': + return PresentationUrl.parseDialUrl_(url); + case 'https:': + + return PresentationUrl.parseLegacyUrl_(url); + default: + PresentationUrl.logger_.fine('Unhandled protocol: ' + url.protocol); + return null; + } + } + + /** + * Parses the given URL using the new DIAL URL format, which takes the form: + * dial:<App name>?postData=<base64-encoded launch parameters> + * @param {!URL} url + * @return {?PresentationUrl} + * @private + */ + static parseDialUrl_(url) { + const appName = url.pathname; + if (!appName.match(/^\w+$/)) { + PresentationUrl.logger_.warning('Invalid app name: ' + appName); + return null; + } + let postData = url.searchParams.get('postData') || undefined; + if (postData) { + try { + postData = base64.decodeString(postData); + } catch (err) { + PresentationUrl.logger_.warning( + 'Invalid base64 encoded postData:' + postData); + return null; + } + } + return new PresentationUrl(appName, postData); + } + + /** + * Parses the given URL using the legacy format specified in + * http://goo.gl/8qKAE7 + * Example: + * http://www.youtube.com/tv#__dialAppName__=YouTube/__dialPostData__=dj0xMjM= + * @param {!URL} url + * @return {?PresentationUrl} + * @private + */ + static parseLegacyUrl_(url) { + // Parse URI and get fragment. + const fragment = url.hash; + if (!fragment) return null; + let appName = PresentationUrl.APP_NAME_REGEX_.exec(fragment); + appName = appName ? appName[1] : null; + if (!appName) return null; + appName = decodeURIComponent(appName); + + let postData = PresentationUrl.LAUNCH_PARAM_REGEX_.exec(fragment); + postData = postData ? postData[1] : undefined; + if (postData) { + try { + postData = base64.decodeString(postData); + } catch (err) { + PresentationUrl.logger_.warning( + 'Invalid base64 encoded postData:' + postData); + return null; + } + } + return new PresentationUrl(appName, postData); + } +}; + + +/** @const @private {?Logger} */ +PresentationUrl.logger_ = Logger.getInstance('mr.dial.PresentationUrl'); + + +/** @const {string} */ +PresentationUrl.URN_PREFIX = 'urn:dial-multiscreen-org:dial:application:'; + + +/** @private @const {!RegExp} */ +PresentationUrl.APP_NAME_REGEX_ = + /__dialAppName__=([A-Za-z0-9-._~!$&'()*+,;=%]+)/; + + +/** @private @const {!RegExp} */ +PresentationUrl.LAUNCH_PARAM_REGEX_ = /__dialPostData__=([A-Za-z0-9]+={0,2})/; + + +exports = PresentationUrl; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/presentation_url_test.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/presentation_url_test.js new file mode 100644 index 00000000000..4477e5ee404 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/presentation_url_test.js @@ -0,0 +1,75 @@ +// 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. + +goog.module('PresentationUrlTest'); +goog.setTestOnly('PresentationUrlTest'); + +const PresentationUrl = goog.require('mr.dial.PresentationUrl'); + +describe('Tests PresentationUrl', function() { + it('Does not create from empty input', function() { + expect(PresentationUrl.create('')).toBeNull(); + }); + + it('Creates from a valid URL', function() { + expect(PresentationUrl.create( + 'https://www.youtube.com/tv#__dialAppName__=YouTube')) + .toEqual(new PresentationUrl('YouTube')); + }); + + it('Creates from a valid URL with launch parameters', function() { + expect(PresentationUrl.create( + 'https://www.youtube.com/tv#' + + '__dialAppName__=YouTube/__dialPostData__=dj0xMjM=')) + .toEqual(new PresentationUrl('YouTube', 'v=123')); + expect(PresentationUrl.create( + 'https://www.youtube.com/tv#' + + '__dialAppName__=YouTube/__dialPostData__=dj1NSnlKS3d6eEZwWQ==')) + .toEqual(new PresentationUrl('YouTube', 'v=MJyJKwzxFpY')); + }); + + it('Does not create from an invalid URL', function() { + expect(PresentationUrl.create( + 'https://www.youtube.com/tv#___emanPpaLiad__=YouTube')) + .toBeNull(); + }); + + it('Does not create from an invalid postData', function() { + expect(PresentationUrl.create( + 'https://www.youtube.com/tv#___emanPpaLiad__=YouTube' + + '/__dialPostData__=dj1=N')) + .toBeNull(); + }); + + it('Creates from DIAL URL', () => { + expect(PresentationUrl.create('dial:YouTube')) + .toEqual(new PresentationUrl('YouTube')); + expect(PresentationUrl.create('dial:YouTube?foo=bar')) + .toEqual(new PresentationUrl('YouTube')); + expect(PresentationUrl.create('dial:YouTube?foo=bar&postData=dj0xMjM=')) + .toEqual(new PresentationUrl('YouTube', 'v=123')); + expect(PresentationUrl.create('dial:YouTube?postData=dj0xMjM%3D')) + .toEqual(new PresentationUrl('YouTube', 'v=123')); + }); + + it('Does not create from invalid DIAL URL', () => { + expect(PresentationUrl.create('dial:')).toBeNull(); + expect(PresentationUrl.create('dial://')).toBeNull(); + expect(PresentationUrl.create('dial://YouTube')).toBeNull(); + expect( + PresentationUrl.create('dial:YouTube?postData=notEncodedProperly111')) + .toBeNull(); + }); + + it('Does not create from URL of unknown protocol', () => { + expect(PresentationUrl.create('unknown:YouTube')).toBeNull(); + }); + + it('getPresentationUrl returns DIAL presentation URLs', () => { + expect(PresentationUrl.getPresentationUrlAsString('YouTube', null)) + .toEqual('dial:YouTube'); + expect(PresentationUrl.getPresentationUrlAsString('YouTube', 'dj0xMjM=')) + .toEqual('dial:YouTube?postData=dj0xMjM%3D'); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/sink_app_status.js b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/sink_app_status.js new file mode 100644 index 00000000000..5acd9a9a8fa --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/providers/dial/sink_app_status.js @@ -0,0 +1,23 @@ +// 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. + +/** + * @fileoverview The availability of an app on a sink. + */ + +goog.provide('mr.dial.SinkAppStatus'); + + +/** + * Tracks the availability of an app on a sink. Apps start out in an + * UNKNOWN status and are changed to AVAILABLE or UNAVAILABLE once the status is + * known, i.e. after we query the sink for the app. + * + * @enum {string} + */ +mr.dial.SinkAppStatus = { + AVAILABLE: 'available', + UNAVAILABLE: 'unavailable', + UNKNOWN: 'unknown' +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/analytics.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/analytics.js new file mode 100644 index 00000000000..8761cc404d7 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/analytics.js @@ -0,0 +1,305 @@ +// 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. + +/** @fileoverview API for Analytics events. */ + +goog.provide('mr.Analytics'); +goog.provide('mr.LongTiming'); +goog.provide('mr.MediumTiming'); +goog.provide('mr.Timing'); + +goog.require('mr.Logger'); + + +/** + * Begins the timing period. + */ +mr.Timing = class { + /** + * @param {!string} name + */ + constructor(name) { + /** @private {!string} */ + this.name_ = name; + + /** @private {number} */ + this.startTime_ = Date.now(); + } + + /** + * Gets the full name with the suffix appended if provided. + * @param {string} name The name of the event or histogram. + * @param {string=} opt_suffix The optional suffix to add. + * @return {string} The full name with suffix added. + * @private + */ + getFullName_(name, opt_suffix) { + if (opt_suffix != null) { + name += '_' + opt_suffix; + } + return name; + } + + /** + * Sets the name for the timing object. + * @param {string} name The new name. + */ + setName(name) { + this.name_ = name; + } + + /** + * Ends the timing period and reports to UMA. + * @param {string=} opt_suffix An optional suffix. + */ + end(opt_suffix) { + const duration = Date.now() - this.startTime_; + const name = this.getFullName_(this.name_, opt_suffix); + mr.Timing.recordDuration(name, duration); + } + + /** + * Sends a short duration value (up to 10 seconds) for analytics collection. + * @param {string} name + * @param {number} duration Duration in milliseconds. + */ + static recordDuration(name, duration) { + if (duration < 0) { + mr.Timing.logger_.warning('Timing analytics event with negative time'); + duration = 0; + } + + if (duration > mr.Timing.TEN_SECONDS_) { + duration = mr.Timing.TEN_SECONDS_; + } + + try { + chrome.metricsPrivate.recordTime(name, duration); + } catch (e) { + mr.Timing.logger_.warning( + 'Failed to record time ' + duration + ' in ' + name); + } + } +}; + + +/** @private const */ +mr.Timing.logger_ = mr.Logger.getInstance('mr.Timing'); + + +/** + * Ten seconds in milliseconds. + * @const {number} + * @private + */ +mr.Timing.TEN_SECONDS_ = 10 * 1000; + + +/** + * Begins a medium timing period (should be measured in seconds). + */ +mr.MediumTiming = class extends mr.Timing { + /** + * @param {string} name The histogram name. + */ + constructor(name) { + super(name); + } + + /** + * @override + */ + end(opt_suffix) { + const duration = Date.now() - this.startTime_; + const name = this.getFullName_(this.name_, opt_suffix); + mr.MediumTiming.recordDuration(name, duration); + } + + /** + * Sends a medium duration value (up to 3 minutes) for analytics collection. + * @param {string} name + * @param {number} duration Duration in milliseconds. + */ + static recordDuration(name, duration) { + if (duration < 0) { + mr.MediumTiming.logger_.warning( + 'Timing analytics event with negative time'); + return; + } + + if (duration < mr.Timing.TEN_SECONDS_) { + duration = mr.Timing.TEN_SECONDS_; + } + + if (duration > mr.MediumTiming.THREE_MINUTES_) { + duration = mr.MediumTiming.THREE_MINUTES_; + } + + try { + chrome.metricsPrivate.recordMediumTime(name, duration); + } catch (e) { + mr.MediumTiming.logger_.warning( + 'Failed to record time ' + duration + ' in ' + name); + } + } +}; + + +/** @private @const */ +mr.MediumTiming.logger_ = mr.Logger.getInstance('mr.MediumTiming'); + + +/** + * Constant of 3 minutes (in milliseconds). + * @private @const {number} + **/ +mr.MediumTiming.THREE_MINUTES_ = 3 * 60 * 1000; + + +/** + * Begins a long timing period (up to 1 hour). + */ +mr.LongTiming = class extends mr.Timing { + /** + * @param {string} name The name of the histogram. + */ + constructor(name) { + super(name); + } + + /** + * @override + */ + end(opt_suffix) { + const duration = Date.now() - this.startTime_; + const name = this.getFullName_(this.name_, opt_suffix); + mr.LongTiming.recordDuration(name, duration); + } + + /** + * Sends a long duration value (up to 1 hour) for analytics collection. + * @param {string} name + * @param {number} duration Duration in milliseconds. + */ + static recordDuration(name, duration) { + if (duration < 0) { + mr.LongTiming.logger_.warning( + 'Timing analytics event with negative time'); + return; + } + + if (duration < mr.MediumTiming.THREE_MINUTES_) { + duration = mr.MediumTiming.THREE_MINUTES_; + } + + if (duration > mr.LongTiming.ONE_HOUR_) { + duration = mr.LongTiming.ONE_HOUR_; + } + + try { + chrome.metricsPrivate.recordLongTime(name, duration); + } catch (e) { + mr.LongTiming.logger_.warning( + 'Failed to record time ' + duration + ' in ' + name); + } + } +}; + + +/** @private @const */ +mr.LongTiming.logger_ = mr.Logger.getInstance('mr.LongTiming'); + + +/** + * Constant of 1 hour (in milliseconds). + * @private @const {number} + **/ +mr.LongTiming.ONE_HOUR_ = 60 * 60 * 1000; + + +/** @const {*} */ +mr.Analytics = {}; + + +/** + * @const {mr.Logger} + * @private + */ +mr.Analytics.logger_ = mr.Logger.getInstance('mr.Analytics'); + + +/** + * Sends a user action for analytics collection. + * @param {!string} name + */ +mr.Analytics.recordEvent = function(name) { + try { + chrome.metricsPrivate.recordUserAction(name); + } catch (e) { + mr.Analytics.logger_.warning('Failed to record event ' + name); + } +}; + + +/** + * Send a value for analytics collection. + * @param {!string} name + * @param {!number} value + * @param {!Object<string,number>} values + */ +mr.Analytics.recordEnum = function(name, value, values) { + let foundKey; + let size = 0; + for (let key in values) { + size++; + if (values[key] == value) { + foundKey = key; + } + } + if (!foundKey) { + mr.Analytics.logger_.error( + 'Unknown analytics value, ' + value + ' for histogram, ' + name, + Error() /* for stack trace */); + return; + } + + const config = { + 'metricName': name, + 'type': 'histogram-linear', + 'min': 1, + 'max': size, + // Add one for the underflow bucket. + 'buckets': size + 1 + }; + + try { + chrome.metricsPrivate.recordValue(config, value); + } catch (/** Error */ e) { + mr.Analytics.logger_.warning( + 'Failed to record enum value ' + foundKey + ' (' + value + ') in ' + + name, + e); + } +}; + + +/** + * Records a small count (0 to 100) for analytics collection. + * @param {string} name + * @param {number} count + */ +mr.Analytics.recordSmallCount = function(name, count) { + try { + if (count < 0) { + throw new Error(`Invalid count for ${name}: ${count}`); + } else if (count > 100) { + mr.Analytics.logger_.warning( + `Small count for ${name} exceeded limits: ${count}`, Error()); + } + chrome.metricsPrivate.recordSmallCount(name, count); + } catch (/** Error */ e) { + mr.Analytics.logger_.warning( + `Failed to record small count ${name} (${count})`, e); + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/analytics_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/analytics_test.js new file mode 100644 index 00000000000..e9f6ee2274c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/analytics_test.js @@ -0,0 +1,246 @@ +// 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. + +goog.setTestOnly(); +goog.require('mr.Analytics'); +goog.require('mr.LongTiming'); +goog.require('mr.MediumTiming'); +goog.require('mr.MockClock'); +goog.require('mr.Timing'); + +describe('Tests Analytics', function() { + let mockClock; + + const TEN_SECONDS = 10 * 1000; + const THREE_MINUTES = 3 * 60 * 1000; + const ONE_HOUR = 60 * 60 * 1000; + + beforeEach(function() { + mockClock = new mr.MockClock(true); + chrome.metricsPrivate = { + recordTime: jasmine.createSpy('recordTime'), + recordMediumTime: jasmine.createSpy('recordMediumTime'), + recordLongTime: jasmine.createSpy('recordLongTime'), + recordUserAction: jasmine.createSpy('recordUserAction'), + recordValue: jasmine.createSpy('recordValue'), + recordSmallCount: jasmine.createSpy('recordSmallCount'), + }; + }); + + afterEach(function() { + mockClock.uninstall(); + }); + + describe('Test Timing Events', function() { + describe('Test mr.Timing', function() { + it('Should record the time passing', function() { + const histogramName = 'Test'; + const timeToPass = 34; + const timing = new mr.Timing(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordTime) + .toHaveBeenCalledWith(histogramName, timeToPass); + }); + it('Should record the time passing with a suffix', function() { + const histogramName = 'Test'; + const suffixName = 'Test'; + const expectedFinalName = histogramName + '_' + suffixName; + const timeToPass = 34; + const timing = new mr.Timing(histogramName); + mockClock.tick(timeToPass); + timing.end(suffixName); + expect(chrome.metricsPrivate.recordTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordTime) + .toHaveBeenCalledWith(expectedFinalName, timeToPass); + }); + it('Should record the max if duration exceeds ten seconds', function() { + const histogramName = 'Test'; + const timeToPass = TEN_SECONDS + 1; + const timing = new mr.Timing(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordTime) + .toHaveBeenCalledWith(histogramName, TEN_SECONDS); + }); + it('Should record the minimum if duration is negative', function() { + const histogramName = 'Test'; + const timeToPass = -1; + const timing = new mr.Timing(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordTime) + .toHaveBeenCalledWith(histogramName, 0); + }); + }); + describe('Test mr.MediumTiming', function() { + it('Should record the time passing', function() { + const histogramName = 'Test'; + const timeToPass = 34 * 1000; + const timing = new mr.MediumTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordMediumTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordMediumTime) + .toHaveBeenCalledWith(histogramName, timeToPass); + }); + it('Should record the time passing with a suffix', function() { + const histogramName = 'Test'; + const suffixName = 'Test'; + const expectedFinalName = histogramName + '_' + suffixName; + const timeToPass = 34 * 1000; + const timing = new mr.MediumTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(suffixName); + expect(chrome.metricsPrivate.recordMediumTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordMediumTime) + .toHaveBeenCalledWith(expectedFinalName, timeToPass); + }); + it('Should record ten seconds if duration is below ten seconds', + function() { + const histogramName = 'Test'; + const timeToPass = 34; + const timing = new mr.MediumTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordMediumTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordMediumTime) + .toHaveBeenCalledWith(histogramName, TEN_SECONDS); + }); + it('Should record three minutes if duration exceeds three minutes', + function() { + const histogramName = 'Test'; + const timeToPass = THREE_MINUTES + 1; + const timing = new mr.MediumTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordMediumTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordMediumTime) + .toHaveBeenCalledWith(histogramName, THREE_MINUTES); + }); + }); + describe('Test mr.LongTiming', function() { + it('Should record the time passing', function() { + const histogramName = 'Test'; + const timeToPass = 34 * 60 * 1000; + const timing = new mr.LongTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordLongTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordLongTime) + .toHaveBeenCalledWith(histogramName, timeToPass); + }); + it('Should record the time passing with a suffix', function() { + const histogramName = 'Test'; + const suffixName = 'Test'; + const expectedFinalName = histogramName + '_' + suffixName; + const timeToPass = 34 * 60 * 1000; + const timing = new mr.LongTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(suffixName); + expect(chrome.metricsPrivate.recordLongTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordLongTime) + .toHaveBeenCalledWith(expectedFinalName, timeToPass); + }); + it('Should record three minutes if duration is below three minutes', + function() { + const histogramName = 'Test'; + const timeToPass = 2 * 60 * 1000; + const timing = new mr.LongTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordLongTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordLongTime) + .toHaveBeenCalledWith(histogramName, THREE_MINUTES); + }); + it('Should record one hour if duration exceeds one hour', function() { + const histogramName = 'Test'; + const timeToPass = ONE_HOUR + 1; + const timing = new mr.LongTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordLongTime.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordLongTime) + .toHaveBeenCalledWith(histogramName, ONE_HOUR); + }); + it('Should not record the time if it we went back in time', function() { + const histogramName = 'Test'; + const timeToPass = -1; + const timing = new mr.LongTiming(histogramName); + mockClock.tick(timeToPass); + timing.end(); + expect(chrome.metricsPrivate.recordLongTime.calls.count()).toBe(0); + }); + }); + }); + describe('Test recordEvent', function() { + it('Should record an event', function() { + const eventName = 'Test'; + mr.Analytics.recordEvent(eventName); + expect(chrome.metricsPrivate.recordUserAction.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordUserAction) + .toHaveBeenCalledWith(eventName); + }); + }); + describe('Test recordEnum', function() { + const testHistogram = 'Test'; + const testValues = {TEST1: 0, TEST2: 1, TEST3: 2}; + const testConfig = { + 'metricName': testHistogram, + 'type': 'histogram-linear', + 'min': 1, + 'max': 3, + 'buckets': 4 + }; + it('Should record an event with corrct index of 0', function() { + mr.Analytics.recordEnum(testHistogram, testValues.TEST1, testValues); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 0); + }); + it('Should record an event with corrct index of 1', function() { + mr.Analytics.recordEnum(testHistogram, testValues.TEST2, testValues); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 1); + }); + it('Should record an event with correct index of 2', function() { + mr.Analytics.recordEnum(testHistogram, testValues.TEST3, testValues); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordValue) + .toHaveBeenCalledWith(testConfig, 2); + }); + it('Should not record an event with an unknown value', function() { + mr.Analytics.recordEnum(testHistogram, 3, testValues); + expect(chrome.metricsPrivate.recordValue.calls.count()).toBe(0); + }); + }); + describe('Test recordSmallCount', () => { + it('Record 0 count succeeds', () => { + mr.Analytics.recordSmallCount('smallCount', 0); + expect(chrome.metricsPrivate.recordSmallCount.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordSmallCount) + .toHaveBeenCalledWith('smallCount', 0); + }); + it('Record negative count fails', () => { + mr.Analytics.recordSmallCount('smallCount', -1); + expect(chrome.metricsPrivate.recordSmallCount.calls.count()).toBe(0); + }); + it('Record large count succeeds', () => { + mr.Analytics.recordSmallCount('smallCount', 200); + expect(chrome.metricsPrivate.recordSmallCount.calls.count()).toBe(1); + expect(chrome.metricsPrivate.recordSmallCount) + .toHaveBeenCalledWith('smallCount', 200); + }); + it('Record regular count succeeds', () => { + mr.Analytics.recordSmallCount('smallCount', 1); + mr.Analytics.recordSmallCount('smallCount', 50); + mr.Analytics.recordSmallCount('smallCount', 100); + expect(chrome.metricsPrivate.recordSmallCount.calls.count()).toBe(3); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/assertions.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/assertions.js new file mode 100644 index 00000000000..93abbb7d8db --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/assertions.js @@ -0,0 +1,95 @@ +// 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. + +/** + * @fileoverview Various assert-like functions. + */ +goog.module('mr.Assertions'); +goog.module.declareLegacyNamespace(); + +const Config = goog.require('mr.Config'); + + +/** + * Given an unknown value, return it if it is an Error, or return a new Error + * otherwise. Note that unlike other methods in the module, it does not throw + * exceptions. + * + * @param {*} err The purported error + * @param {string=} opt_message The message used to construct an Error if |err| + * is not an error. + * @return {!Error} + */ +exports.toError = function(err, opt_message) { + if (err instanceof Error) { + return err; + } else { + return Error(opt_message || `Expected an Error value, got ${err}`); + } +}; + + +/** + * Represents an assertion failure. + */ +const AssertionError = class extends Error { + /** + * @param {string=} message Error message. + */ + constructor(message = '') { + super(); + this.name = 'AssertionError'; + this.message = message; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AssertionError); + } else { + this.stack = new Error().stack; + } + } +}; + + +/** + * Checks if the condition evaluates to true if mr.Config.isDebugChannel is + * true. + * @template T + * @param {T} condition The condition to check. + * @param {string=} message Error message if condition evaluates to false. + * @throws {AssertionError} When the condition evaluates to false. + * @return {T} The condition. + */ +exports.assert = function(condition, message = undefined) { + if (Config.isDebugChannel && !condition) { + throw new AssertionError(message); + } + return condition; +}; + + +/** + * Checks that a value is a string if mr.Config.isDebugChannel is true. + * @param {*} value The value to check + * @param {string=} message The message + * @return {string} The value + * @throws {AssertionError} if the value is not a string + */ +exports.assertString = function(value, message = undefined) { + if (Config.isDebugChannel && typeof value !== 'string') { + throw new AssertionError(); + } + return /** @type {string} */ (value); +}; + + +/** + * Returns a Promise that rejects with 'Not implemented' as the error + * message. + * @template T + * @return {!Promise<T>} + */ +exports.rejectNotImplemented = function() { + return Promise.reject(new Error('Not implemented')); +}; + +exports.AssertionError = AssertionError; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/base64.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/base64.js new file mode 100644 index 00000000000..5d8b3d1ed08 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/base64.js @@ -0,0 +1,46 @@ +// 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. + +goog.module('mr.base64'); + +/** + * @const {!Map<string, string>} + */ +const URL_SAFE_MAP = new Map().set('+', '-').set('/', '_').set('=', '.'); + + +/** + * @const {!Map<string, string>} + */ +const INVERSE_URL_SAFE_MAP = + new Map().set('-', '+').set('_', '/').set('.', '='); + + +/** + * Decodes a base64 string using either the normal or URL-safe alphabet. + * @param {string} encoded + * @return {string} + */ +function decodeString(encoded) { + return atob(encoded.replace(/[-_.]/g, c => INVERSE_URL_SAFE_MAP.get(c))); +} + + +/** + * Encodes an array of byte values in base64. + * @param {!Array<number>} data An array of byte values. + * @param {boolean} urlSafe If true, uses a URL-safe base64 alphabet. + * @return {string} The encoded data. + */ +function encodeArray(data, urlSafe) { + const encoded = btoa(String.fromCharCode(...data)); + return urlSafe ? encoded.replace(/[+/=]/g, c => URL_SAFE_MAP.get(c)) : + encoded; +} + + +exports = { + decodeString, + encodeArray, +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/base64_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/base64_test.js new file mode 100644 index 00000000000..07240a4f82d --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/base64_test.js @@ -0,0 +1,72 @@ +// 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. + +goog.module('mr.base64.test'); +goog.setTestOnly(); + +const {encodeArray, decodeString} = goog.require('mr.base64'); + +/** + * Converts a ASCII string to an array of bytes. + * @param {string} s + * @return {!Array<number>} + */ +function strBytes(s) { + return s.split('').map(c => c.codePointAt(0)); +} + +describe('mr.base64.encodeArray', () => { + it('encodes well-known values correctly', () => { + expect(encodeArray(strBytes(''))).toBe(''); + expect(encodeArray(strBytes('f'))).toBe('Zg=='); + expect(encodeArray(strBytes('fo'))).toBe('Zm8='); + expect(encodeArray(strBytes('foo'))).toBe('Zm9v'); + expect(encodeArray(strBytes('foob'))).toBe('Zm9vYg=='); + expect(encodeArray(strBytes('fooba'))).toBe('Zm9vYmE='); + expect(encodeArray(strBytes('foobar'))).toBe('Zm9vYmFy'); + expect( + encodeArray(strBytes( + '\xe4\xb8\x80\xe4\xba\x8c\xe4\xb8\x89\xe5\x9b\x9b\xe4\xba\x94\xe5' + + '\x85\xad\xe4\xb8\x83\xe5\x85\xab\xe4\xb9\x9d\xe5\x8d\x81'))) + .toBe('5LiA5LqM5LiJ5Zub5LqU5YWt5LiD5YWr5Lmd5Y2B'); + expect(encodeArray(strBytes('>>>???>>>???=/+'))) + .toBe('Pj4+Pz8/Pj4+Pz8/PS8r'); + }); + + it('handles the urlSafe parameter correctly', () => { + expect(encodeArray(strBytes('f'), true)).toBe('Zg..'); + expect(encodeArray(strBytes('fo'), true)).toBe('Zm8.'); + expect(encodeArray(strBytes('foo'), true)).toBe('Zm9v'); + expect(encodeArray(strBytes('foob'), true)).toBe('Zm9vYg..'); + expect(encodeArray(strBytes('fooba'), true)).toBe('Zm9vYmE.'); + expect(encodeArray(strBytes('foobar'), true)).toBe('Zm9vYmFy'); + expect(encodeArray(strBytes('>>>???>>>???=/+'), true)) + .toBe('Pj4-Pz8_Pj4-Pz8_PS8r'); + }); + + it('decodes correctly with the standard alphabet', () => { + expect(decodeString('')).toBe(''); + expect(decodeString('Zg==')).toBe('f'); + expect(decodeString('Zm8=')).toBe('fo'); + expect(decodeString('Zm9v')).toBe('foo'); + expect(decodeString('Zm9vYg==')).toBe('foob'); + expect(decodeString('Zm9vYmE=')).toBe('fooba'); + expect(decodeString('Zm9vYmFy')).toBe('foobar'); + expect(decodeString('5LiA5LqM5LiJ5Zub5LqU5YWt5LiD5YWr5Lmd5Y2B')) + .toBe( + '\xe4\xb8\x80\xe4\xba\x8c\xe4\xb8\x89\xe5\x9b\x9b\xe4\xba\x94\xe5' + + '\x85\xad\xe4\xb8\x83\xe5\x85\xab\xe4\xb9\x9d\xe5\x8d\x81'); + expect(decodeString('Pj4+Pz8/Pj4+Pz8/PS8r')).toBe('>>>???>>>???=/+'); + }); + + it('decodes correctly with the URL-safe alphabet', () => { + expect(decodeString('Zg..')).toBe('f'); + expect(decodeString('Zm8.')).toBe('fo'); + expect(decodeString('Zm9v')).toBe('foo'); + expect(decodeString('Zm9vYg..')).toBe('foob'); + expect(decodeString('Zm9vYmE.')).toBe('fooba'); + expect(decodeString('Zm9vYmFy')).toBe('foobar'); + expect(decodeString('Pj4-Pz8_Pj4-Pz8_PS8r')).toBe('>>>???>>>???=/+'); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/device_counts.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/device_counts.js new file mode 100644 index 00000000000..d8f7392dc69 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/device_counts.js @@ -0,0 +1,18 @@ +// 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. + +goog.module('mr.DeviceCounts'); +goog.module.declareLegacyNamespace(); + + +/** + * A struct to hold a snapshot of device counts for a sink discovery service. + * @typedef {{ + * availableDeviceCount: number, + * knownDeviceCount: number + * }} + */ +let DeviceCounts; + +exports = DeviceCounts; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/device_counts_provider.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/device_counts_provider.js new file mode 100644 index 00000000000..a76ecc07702 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/device_counts_provider.js @@ -0,0 +1,23 @@ +// 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. + +goog.module('mr.DeviceCountsProvider'); +goog.module.declareLegacyNamespace(); + +const DeviceCounts = goog.require('mr.DeviceCounts'); + +/** + * Implemented by services that are capable of providing counts of devices that + * they manage. + * @record + */ +const DeviceCountsProvider = class { + /** + * Returns the device counts currently known to the service. + * @return {!DeviceCounts} + */ + getDeviceCounts() {} +}; + +exports = DeviceCountsProvider; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/event_analytics.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/event_analytics.js new file mode 100644 index 00000000000..f9fbde5e388 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/event_analytics.js @@ -0,0 +1,57 @@ +// 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. + +/** @fileoverview Analytics for events. */ + +goog.provide('mr.EventAnalytics'); +goog.provide('mr.EventAnalytics.Event'); + +goog.require('mr.Analytics'); + +/** + * Possible event types that can wake the event page. Keep names in sync with + * extensions/browser/extension_event_histogram_value.h in Chromium and values + * in sync with MediaRouterWakeEventType in + * google3/analysis/uma/configs/chrome/histograms.xml. + * + * @enum {number} + */ +mr.EventAnalytics.Event = { + // Special value meaning the event page was woken by the Media Router and not + // a regular extension event. + MEDIA_ROUTER: 0, + CAST_CHANNEL_ON_ERROR: 1, + CAST_CHANNEL_ON_MESSAGE: 2, + DIAL_ON_DEVICE_LIST: 3, + DIAL_ON_ERROR: 4, + GCM_ON_MESSAGE: 5, + IDENTITY_ON_SIGN_IN_CHANGED: 6, + MDNS_ON_SERVICE_LIST: 7, + NETWORKING_PRIVATE_ON_NETWORKS_CHANGED: 8, + NETWORKING_PRIVATE_ON_NETWORK_LIST_CHANGED: 9, + PROCESSES_ON_UPDATED: 10, + RUNTIME_ON_MESSAGE: 11, + RUNTIME_ON_MESSAGE_EXTERNAL: 12, + SETTINGS_PRIVATE_ON_PREFS_CHANGED: 13, + TABS_ON_UPDATED: 14, +}; + +/** + * @private {mr.EventAnalytics.Event} The event that woke the event page. + */ +mr.EventAnalytics.firstEvent_; + +/** + * Records an event handler invocation in the event page. If it is the first + * event that woke the page, a histogram is recorded. Subsequent events are a + * no-op. + * + * @param {!mr.EventAnalytics.Event} eventType The event type. + */ +mr.EventAnalytics.recordEvent = function(eventType) { + if (mr.EventAnalytics.firstEvent_ != undefined) return; + mr.Analytics.recordEnum( + 'MediaRouter.Provider.WakeEvent', eventType, mr.EventAnalytics.Event); + mr.EventAnalytics.firstEvent_ = eventType; +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/event_analytics_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/event_analytics_test.js new file mode 100644 index 00000000000..5be671d2ccb --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/event_analytics_test.js @@ -0,0 +1,27 @@ +// 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. + +goog.setTestOnly(); +goog.require('mr.Analytics'); +goog.require('mr.EventAnalytics'); + +describe('Tests EventAnalytics', () => { + + beforeEach(() => { + mr.EventAnalytics.firstEvent_ = undefined; + }); + + describe('Test recordEvent', () => { + it('should record only the first event', () => { + spyOn(mr.Analytics, 'recordEnum'); + mr.EventAnalytics.recordEvent(mr.EventAnalytics.Event.DIAL_ON_ERROR); + mr.EventAnalytics.recordEvent(mr.EventAnalytics.Event.TABS_ON_UPDATED); + expect(mr.Analytics.recordEnum.calls.count()).toEqual(1); + expect(mr.Analytics.recordEnum) + .toHaveBeenCalledWith( + 'MediaRouter.Provider.WakeEvent', + mr.EventAnalytics.Event.DIAL_ON_ERROR, mr.EventAnalytics.Event); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/event_target.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/event_target.js new file mode 100644 index 00000000000..8fbae73ddf1 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/event_target.js @@ -0,0 +1,69 @@ +// 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. + +goog.module('mr.EventTarget'); +goog.module.declareLegacyNamespace(); + + +/** @final */ +class EventTarget { + constructor() { + /** @private @const {!Array<!Subscription>} */ + this.subscriptions_ = []; + } + + /** + * @param {string} type The event type id. + * @param {function(this:T, ?)} handler Callback + * @param {T=} target + * @template T + */ + listen(type, handler, target = undefined) { + this.subscriptions_.push({type, handler, target}); + } + + /** + * @param {string} type The event type id. + * @param {function(this:T, ?)} handler Callback + * @param {T=} target + * @template T + */ + unlisten(type, handler, target = undefined) { + const index = this.subscriptions_.findIndex( + sub => + sub.type == type && sub.handler == handler && sub.target == target); + if (index != -1) { + this.subscriptions_.splice(index, 1); + } + } + + /** + * @param {{type: string}} event + */ + dispatchEvent(event) { + this.subscriptions_.forEach(sub => { + if (sub.type == event.type) { + // Call handler asynchronously so exceptions don't show up at the source + // of the event. + Promise.resolve().then(() => sub.handler.call(sub.target, event)); + } + }); + } +} + + +/** @record */ +const Subscription = class {}; + +/** @type {string} */ +Subscription.prototype.type; + +/** @type {function(?)} */ +Subscription.prototype.handler; + +/** @type {Object|undefined} */ +Subscription.prototype.target; + + +exports = EventTarget; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/fixed_size_queue.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/fixed_size_queue.js new file mode 100644 index 00000000000..7f8cb7c23a1 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/fixed_size_queue.js @@ -0,0 +1,126 @@ +// 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. + +/** + * @fileoverview FIFO with a hard limit on its size. + + */ + +goog.module('mr.FixedSizeQueue'); +goog.module.declareLegacyNamespace(); + + +/** + * A fixed-sized buffer with FIFO semantics. + * @template T + */ +class FixedSizeQueue { + /** + * @param {number} maxSize The size of the buffer. + */ + constructor(maxSize) { + if (maxSize <= 0) { + throw Error('invalid buffer size'); + } + + /** + * Items are popped from here. Elements are stored in reverse + * insertion order. + * @private @type {!Array<T>} + */ + this.head_ = []; + + /** + * Items are pushed here. Elements are stored in insertion order. + * @private @type {!Array<T>} + */ + this.tail_ = []; + + /** + * @private @const + */ + this.maxSize_ = maxSize; + } + + /** + * Adds an item to the buffer. Drops the last added item if the + * buffer is full. + * @param {T} item + */ + enqueue(item) { + if (this.getCount() >= this.maxSize_) { + this.dequeue(); + } + this.tail_.push(item); + } + + /** + * Removes the oldest item from the buffer, which must be non-empty. + * @return {T} The removed item. + */ + dequeue() { + if (this.isEmpty()) { + throw Error('Empty queue'); + } + if (this.head_.length == 0) { + this.head_ = this.tail_; + this.head_.reverse(); + this.tail_ = []; + } + return this.head_.pop(); + } + + /** + * Removes and returns all items in the buffer in insertion order. + * @return {!Array<T>} + */ + dequeueAll() { + const result = this.getValues(); + this.clear(); + return result; + } + + /** + * @return {number} The number of items in the buffer. + */ + getCount() { + return this.head_.length + this.tail_.length; + } + + /** + * @return {boolean} True if the buffer is full. + */ + isFull() { + return this.getCount() == this.maxSize_; + } + + /** + * @return {boolean} True if the buffer is empty. + */ + isEmpty() { + return this.getCount() == 0; + } + + /** + * Gets all the items in the buffer in insertion order. + * @return {!Array<T>} + */ + getValues() { + const result = this.head_.slice(); // clones array + result.reverse(); + result.push(...this.tail_); + return result; + } + + /** + * Makes the buffer empty. + */ + clear() { + this.head_ = []; + this.tail_ = []; + } +} + + +exports = FixedSizeQueue; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/fixed_size_queue_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/fixed_size_queue_test.js new file mode 100644 index 00000000000..09f0bff6ae9 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/fixed_size_queue_test.js @@ -0,0 +1,74 @@ +// 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. + +goog.require('mr.FixedSizeQueue'); + +describe('mr.FixedSizeQueue', function() { + let queue; + + beforeEach(function() { + queue = new mr.FixedSizeQueue(3); + }); + + it('works', function() { + expect(queue.getCount()).toBe(0); + expect(queue.isFull()).toBe(false); + expect(queue.isEmpty()).toBe(true); + expect(queue.getValues()).toEqual([]); + + queue.enqueue(1); + expect(queue.getCount()).toBe(1); + expect(queue.isFull()).toBe(false); + expect(queue.isEmpty()).toBe(false); + expect(queue.getValues()).toEqual([1]); + + queue.enqueue(2); + queue.enqueue(3); + expect(queue.getCount()).toBe(3); + expect(queue.isFull()).toBe(true); + expect(queue.isEmpty()).toBe(false); + expect(queue.getValues()).toEqual([1, 2, 3]); + + queue.enqueue(4); + expect(queue.getCount()).toBe(3); + expect(queue.isFull()).toBe(true); + expect(queue.isEmpty()).toBe(false); + expect(queue.getValues()).toEqual([2, 3, 4]); + + queue.clear(); + expect(queue.getCount()).toBe(0); + expect(queue.isFull()).toBe(false); + expect(queue.isEmpty()).toBe(true); + expect(queue.getValues()).toEqual([]); + }); + + describe('when full', function() { + beforeEach(function() { + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); + expect(queue.getCount()).toBe(3); + expect(queue.isFull()).toBe(true); + }); + + it('supports dequeue', function() { + expect(queue.dequeue()).toBe(1); + expect(queue.getCount()).toBe(2); + expect(queue.getValues()).toEqual([2, 3]); + expect(queue.dequeue()).toBe(2); + expect(queue.getCount()).toBe(1); + expect(queue.getValues()).toEqual([3]); + expect(queue.dequeue()).toBe(3); + expect(queue.getCount()).toBe(0); + expect(queue.getValues()).toEqual([]); + expect(queue.isFull()).toBe(false); + expect(queue.isEmpty()).toBe(true); + }); + + it('supports deqeueAll', function() { + expect(queue.dequeueAll()).toEqual([1, 2, 3]); + expect(queue.isEmpty()).toBe(true); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/logger.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/logger.js new file mode 100644 index 00000000000..49b214a4b90 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/logger.js @@ -0,0 +1,261 @@ +// 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. + +goog.provide('mr.Logger'); + +goog.require('mr.Assertions'); +goog.require('mr.Config'); + + +/** + * An object for recording logs. + */ +mr.Logger = class { + /** + * @param {string} name + */ + constructor(name) { + /** + * @private @const {string} + */ + this.name_ = name; + } + + /** + * @param {string} name + * @return {!mr.Logger} + */ + static getInstance(name) { + let instance = mr.Logger.instances_.get(name); + if (!instance) { + instance = new mr.Logger(name); + mr.Logger.instances_.set(name, instance); + } + return instance; + } + + /** + * @param {function(!mr.Logger.Record)} handler + */ + static addHandler(handler) { + mr.Logger.handlers_.push(handler); + } + + /** + * Logs a pre-built record if its level is high enough. + * @param {!mr.Logger.Record} record + */ + static logRecord(record) { + if (record.level >= mr.Logger.level) { + mr.Logger.handlers_.forEach(handler => handler(record)); + } + } + + /** + * Logs a message at the specified log level with an optional exception. + * + * @param {mr.Logger.Level} level + * @param {mr.Logger.Loggable} message + * @param {*=} exception An exception to associate with the log message. This + * should normally be an Error instance for best results, but any type is + * acceptable. + */ + log(level, message, exception = undefined) { + if (level < mr.Logger.level) { + return; + } + + // Logging will occur at the current logging level. If the message is a + // lazy-evaluated one, eval now. + if (typeof message == 'function') { + message = message(); + } + + // For non-debug builds, make an effort to programmatically scrub message + // text that potentially contains personally-identifying information. + // However, note that this only covers some of the more-obvious forms of + // PII, and no heuristic can ever hope to provide 100% safety. Also, some of + // the regular expressions may sometimes match more or less than what was + // intended. + mr.Assertions.assert( + typeof message == 'string', 'Expected message to be a string.'); + if (!mr.Config.isDebugChannel) { + message = message.replace(mr.Logger.URL_REGEXP_, '[Redacted URL]'); + message = message.replace( + mr.Logger.DOMAIN_OR_EMAIL_REGEXP_, '[Redacted domain/email]'); + message = message.replace(mr.Logger.SINK_ID_REGEXP_, (match, p1, p2) => { + return p1 + ':<' + p2.substr(-4) + '>'; + }); + } + + const record = { + logger: this.name_, + level: level, + time: Date.now(), + message: message, + exception: exception, + }; + mr.Logger.handlers_.forEach(handler => handler(record)); + } + + /** + * @param {mr.Logger.Loggable} message + * @param {*=} exception + */ + error(message, exception = undefined) { + this.log(mr.Logger.Level.SEVERE, message, exception); + } + + /** + * @param {mr.Logger.Loggable} message + * @param {*=} exception + */ + warning(message, exception = undefined) { + this.log(mr.Logger.Level.WARNING, message, exception); + } + + /** + * @param {mr.Logger.Loggable} message + * @param {*=} exception + */ + info(message, exception = undefined) { + this.log(mr.Logger.Level.INFO, message, exception); + } + + /** + * @param {mr.Logger.Loggable} message + * @param {*=} exception + */ + fine(message, exception = undefined) { + this.log(mr.Logger.Level.FINE, message, exception); + } + + /** + * @param {mr.Logger.Level} level + * @return {string} + */ + static levelToString(level) { + return mr.Logger.LEVEL_NAMES_[level]; + } + + /** + * @param {string} levelName + * @param {mr.Logger.Level} defaultLevel + * @return {mr.Logger.Level} + */ + static stringToLevel(levelName, defaultLevel) { + const index = mr.Logger.LEVEL_NAMES_.indexOf(levelName); + return index == -1 ? defaultLevel : /** @type {mr.Logger.Level} */ (index); + } + + /** + * Converts a numeric log level (as used in the Closure library) into a log + * level constant. + * @param {number} levelValue + * @return {mr.Logger.Level} + */ + static numberToLevel(levelValue) { + if (levelValue <= 600) { + return mr.Logger.Level.FINE; + } else if (levelValue <= 850) { + return mr.Logger.Level.INFO; + } else if (levelValue <= 950) { + return mr.Logger.Level.WARNING; + } else { + return mr.Logger.Level.SEVERE; + } + } +}; + + +/** + * @private @const {!Array<function(mr.Logger.Record)>} + */ +mr.Logger.handlers_ = []; + + +/** + * @private @const {!Map<string, !mr.Logger>} + */ +mr.Logger.instances_ = new Map(); + + +/** + * The available log levels. + * @enum {number} + */ +mr.Logger.Level = { + FINE: 0, + INFO: 1, + WARNING: 2, + SEVERE: 3, +}; + + +/** + * The canonical names of log levels in ascending order of severity. + * @private const {!Array<string>} + */ +mr.Logger.LEVEL_NAMES_ = ['FINE', 'INFO', 'WARNING', 'SEVERE']; + + +/** + * A regular expression that matches a very broad-range of text that looks like + * it could be a domain name or an e-mail address. + * @private const {!RegExp} + */ +mr.Logger.DOMAIN_OR_EMAIL_REGEXP_ = + /(([\w.+-]+@)|((www|m|mail|ftp)[.]))[\w.-]+[.][\w-]{2,4}/gi; + + +/** + * A regular expression that matches a very broad-range of text that looks like + * it could be an URL. + * @private const {!RegExp} + */ +mr.Logger.URL_REGEXP_ = /(data:|https?:\/\/)\S+/gi; + + +/** + * A regular expression that matches a very broad-range of text that looks like + * it could be a sink ID. + * @private const {!RegExp} + */ +mr.Logger.SINK_ID_REGEXP_ = /(dial|cast):<([a-zA-Z0-9]+)>/gi; + + +/** + * An abstract represenation of a log message. + * + * The `time` field should be in the format returned by `Date.now()`. The + * `exception` field will typically be an Error instance, but code that handles + * log records must be prepared to handle any type. + * + * @typedef {{ + * level: mr.Logger.Level, + * logger: string, + * time: number, + * message: string, + * exception: *, + * }} + */ +mr.Logger.Record; + + +/** + * @typedef {string|function():string} + */ +mr.Logger.Loggable; + + +/** + * @const + */ +mr.Logger.DEFAULT_LEVEL = mr.Logger.Level.INFO; + + +/** + * @type {mr.Logger.Level} + */ +mr.Logger.level = mr.Logger.DEFAULT_LEVEL; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/logger_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/logger_test.js new file mode 100644 index 00000000000..bb5048642ad --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/logger_test.js @@ -0,0 +1,166 @@ +// 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. + +goog.module('mr.LoggerTest'); +goog.setTestOnly('mr.LoggerTest'); + +const Config = goog.require('mr.Config'); +const Logger = goog.require('mr.Logger'); + +describe('Test mr.Logger', function() { + let originalLevel; + let logger; + + beforeEach(() => { + originalLevel = Logger.level; + logger = new Logger('test'); + }); + + afterEach(() => { + Logger.level = originalLevel; + Logger.handlers_ = []; + }); + + it('logs string messages only at INFO and above', () => { + Logger.level = Logger.Level.WARNING; + const loggedMessages = []; + Logger.addHandler(record => { + expect(record.level).not.toBeLessThan(Logger.Level.WARNING); + expect(record.logger).toEqual('test'); + expect(typeof record.time).toBe('number'); + expect(typeof record.message).toBe('string'); + loggedMessages.push(record.message); + }); + + logger.fine('Should not log this message.'); + logger.info('Should not log this message either.'); + logger.warning('Should log this warning message.'); + logger.error('Should log this error message.'); + + expect(loggedMessages).toEqual([ + 'Should log this warning message.', 'Should log this error message.' + ]); + }); + + it('logs lazy-evaluated messages', () => { + Logger.level = Logger.Level.FINE; + const loggedMessages = []; + Logger.addHandler(record => { + expect(record.level).not.toBeLessThan(Logger.Level.FINE); + expect(record.logger).toEqual('test'); + expect(typeof record.time).toBe('number'); + expect(typeof record.message).toBe('string'); + loggedMessages.push(record.message); + }); + + logger.fine(() => 'Should log this fine message.'); + logger.info(() => 'Should log this info message.'); + logger.warning(() => 'Should log this warning message.'); + logger.error(() => 'Should log this error message.'); + + expect(loggedMessages).toEqual([ + 'Should log this fine message.', 'Should log this info message.', + 'Should log this warning message.', 'Should log this error message.' + ]); + }); + + describe('Personally-identifying info scrubbbing tests', () => { + let loggedMessages; + let isDebugChannelDefault = Config.isDebugChannel; + + beforeEach(() => { + Config.isDebugChannel = false; + loggedMessages = []; + Logger.level = Logger.Level.FINE; + Logger.addHandler(record => { + expect(typeof record.message).toBe('string'); + loggedMessages.push(record.message); + }); + }); + + afterEach(() => { + Config.isDebugChannel = isDebugChannelDefault; + }); + + it('does not scrub non-PII from messages', () => { + // Things that shouldn't be scrubbed. + logger.info(''); + logger.info('42'); + logger.info( + 'Found sink with id: ac6982d68e687faf6ebf8cc (Chromecast Ultra)'); + logger.info('The event occurred at 20:21:22 on 29 Mar 2017.'); + + expect(loggedMessages).toEqual([ + '', '42', + 'Found sink with id: ac6982d68e687faf6ebf8cc (Chromecast Ultra)', + 'The event occurred at 20:21:22 on 29 Mar 2017.' + ]); + }); + + it('scrubs domains', () => { + // Things that look like domain names. + logger.info('Visiting www.google.com...'); + logger.info('Tab favicon domain is: ftp.myfilez.net'); + // The following example shows the RegExp currently used does not match + // against all possible domains perfectly. + logger.info( + 'mail.personaldata.security.biz mapped to ' + + 'personaldata.security.biz.'); + + expect(loggedMessages).toEqual([ + 'Visiting [Redacted domain/email]...', + 'Tab favicon domain is: [Redacted domain/email]', + '[Redacted domain/email] mapped to personaldata.security.biz.' + ]); + }); + + it('scrubs email addresses', () => { + // Things that look like e-mail addresses. + logger.info('Reply to nobody@love-spam.net, and see what happens.'); + logger.info( + 'This CL was written by somebody@developers.chromium.org, ' + + 'or was it somebody@hooli.com?'); + + expect(loggedMessages).toEqual([ + 'Reply to [Redacted domain/email], and see what happens.', + 'This CL was written by [Redacted domain/email], or was it ' + + '[Redacted domain/email]?' + ]); + }); + + it('scrubs URLs', () => { + // Things that look like URLs. + logger.info( + 'Downloading from http://www.pictures.com/gifs/' + + 'kittens%20falling%20of%20furniture.png...'); + logger.info('Page navigation detected: https://youtube.com/profile'); + logger.info( + 'Relpacing content with: ' + + 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D'); + + expect(loggedMessages).toEqual([ + 'Downloading from [Redacted URL]', + 'Page navigation detected: [Redacted URL]', + 'Relpacing content with: [Redacted URL]' + ]); + }); + + it('scrubs sink IDs', () => { + logger.info( + 'Sink has pending connection' + + ' dial:<05f5e10100641000bc6f90f1aaa0bd90>'); + logger.info( + 'Adding new session: cast:<de51d94921f15f8af6dbf65592bb3610>, ' + + '5d85e5da-b773-4382-ba06-43c2a6dc6ba6'); + logger.info('Connecting to (id 1) rf72niQ3FPe8VpTz_tIEGNSkfGUo.'); + logger.info(''); + + expect(loggedMessages).toEqual([ + 'Sink has pending connection dial:<bd90>', + 'Adding new session: cast:<3610>, 5d85e5da-b773-4382-ba06-43c2a6dc6ba6', + 'Connecting to (id 1) rf72niQ3FPe8VpTz_tIEGNSkfGUo.', '' + ]); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/media_source_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/media_source_utils.js new file mode 100644 index 00000000000..e0392b46cf4 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/media_source_utils.js @@ -0,0 +1,127 @@ +// 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. + +/** + * @fileoverview The media source URN related utilities methods. + */ + +goog.provide('mr.MediaSourceUtils'); + +goog.require('mr.Config'); + + + + +/** + * @param {string} sourceUrn + * @return {boolean} True if it is a mirror URN. + */ +mr.MediaSourceUtils.isMirrorSource = function(sourceUrn) { + return mr.MediaSourceUtils.isTabMirrorSource(sourceUrn) || + mr.MediaSourceUtils.isDesktopMirrorSource(sourceUrn); +}; + + +/** + * @param {string} sourceUrn + * @return {boolean} True if it is a two UA mode presentation source. + * A presentation source has a sourceUrn that is a valid uri and + * is not a Cast custom receiver app. + */ +mr.MediaSourceUtils.isPresentationSource = function(sourceUrn) { + + if (!sourceUrn.startsWith('http:') && !sourceUrn.startsWith('https:')) { + return false; + } + // Use the DOM to parse sourceUrn. + const link = document.createElement('a'); + link.href = sourceUrn; + // Protocol must be http or https. + if (link.protocol != 'http:' && link.protocol != 'https:') { + return false; + } + + // Must not be a custom Cast receiver app. + return link.hash.indexOf(mr.MediaSourceUtils.CAST_APP_ID_) == -1; +}; + + + +/** @const {string} */ +mr.MediaSourceUtils.CAST_STREAMING_APP_ID = '0F5096E8'; + + +/** @const {string} */ +mr.MediaSourceUtils.TAB_MIRROR_URN_PREFIX = + 'urn:x-org.chromium.media:source:tab:'; + +/** @const {string} */ +mr.MediaSourceUtils.TAB_REMOTING_URN_PREFIX = + 'urn:x-org.chromium.media:source:tab_content_remoting:'; + +/** @const {string} */ +mr.MediaSourceUtils.DESKTOP_MIRROR_URN = + 'urn:x-org.chromium.media:source:desktop'; + + +/** @private @const {string} */ +mr.MediaSourceUtils.CAST_APP_ID_ = '__castAppId__'; + + +/** + * @private @const {!Array<string>} + */ +mr.MediaSourceUtils.MIRROR_APP_ID_ORIGIN_WHITELIST_ = [ + 'https://docs.google.com', // slides +]; + + +/** + * @param {string} sourceUrn + * @return {?Array<string>} array of origins whitelisted for the sourceUrn or + * |null| if any origin is allowed for the sourceUrn. + */ +mr.MediaSourceUtils.getWhitelistedOrigins = function(sourceUrn) { + if (mr.Config.isDebugChannel && + window.localStorage['debug.allowAllOrigins']) { + return null; + } + return sourceUrn.indexOf(mr.MediaSourceUtils.CAST_STREAMING_APP_ID) != -1 ? + mr.MediaSourceUtils.MIRROR_APP_ID_ORIGIN_WHITELIST_ : + null; +}; + + +/** + * @param {string} sourceUrn + * @return {boolean} True if it is a tab mirror URN. + */ +mr.MediaSourceUtils.isTabMirrorSource = function(sourceUrn) { + return sourceUrn.startsWith(mr.MediaSourceUtils.TAB_MIRROR_URN_PREFIX) || + sourceUrn.indexOf(mr.MediaSourceUtils.CAST_STREAMING_APP_ID) != -1; +}; + + +/** + * @param {string} sourceUrn + * @return {boolean} True if it is a desktop mirror URN. + */ +mr.MediaSourceUtils.isDesktopMirrorSource = function(sourceUrn) { + return sourceUrn == mr.MediaSourceUtils.DESKTOP_MIRROR_URN; +}; + + +/** + * Get the tab ID from |sourceUrn|. Returns null if the sourceUrn is not a tab + * mirror URN or if it doesn't contain a valid tab ID. + * @param {string} sourceUrn + * @return {?number} + */ +mr.MediaSourceUtils.getMirrorTabId = function(sourceUrn) { + const pos = sourceUrn.search(mr.MediaSourceUtils.TAB_MIRROR_URN_PREFIX); + if (pos == -1) return null; + const tabIdStr = + sourceUrn.substr(pos + mr.MediaSourceUtils.TAB_MIRROR_URN_PREFIX.length); + return parseInt(tabIdStr, 10) || null; +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/media_source_utils_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/media_source_utils_test.js new file mode 100644 index 00000000000..9647921874f --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/media_source_utils_test.js @@ -0,0 +1,66 @@ +// 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. + +goog.require('mr.MediaSourceUtils'); + +describe('Tests MediaSourceUtils', function() { + describe('Tests isTabMirrorSource', function() { + it('should return true for tab mirror source', function() { + expect(mr.MediaSourceUtils.isTabMirrorSource( + 'urn:x-org.chromium.media:source:tab:2')) + .toBe(true); + expect(mr.MediaSourceUtils.isTabMirrorSource( + 'urn:x-org.chromium.media:source:tab:666')) + .toBe(true); + }); + + it('should return false for non tab mirror source', function() { + expect(mr.MediaSourceUtils.isTabMirrorSource( + 'urn:x-org.chromium.media:source:desktop')) + .toBe(false); + }); + }); + + describe('Tests isPresentationSource', function() { + it('should return true for presentation source', function() { + expect(mr.MediaSourceUtils.isPresentationSource('http://www.google.com')) + .toBe(true); + expect(mr.MediaSourceUtils.isPresentationSource('https://www.google.com')) + .toBe(true); + }); + + it('should return false for non tab mirror source', function() { + expect( + mr.MediaSourceUtils.isPresentationSource('Invalid media source urn')) + .toBe(false); + }); + + it('should return false for cast receiver app', function() { + expect(mr.MediaSourceUtils.isPresentationSource( + 'http://www.google.com/cast#__castAppId__=deadbeef')) + .toBe(false); + }); + }); + + describe('Tests getMirrorTabId', function() { + it('should return null for non tab mirror source or invalid ID', + function() { + expect(mr.MediaSourceUtils.getMirrorTabId('http://www.google.com')) + .toBeNull(); + expect(mr.MediaSourceUtils.getMirrorTabId( + 'urn:x-org.chromium.media:source:tab:')) + .toBeNull(); + }); + + it('should return correct ID for correct tab mirror source', function() { + expect(mr.MediaSourceUtils.getMirrorTabId( + 'urn:x-org.chromium.media:source:tab:2')) + .toBe(2); + expect(mr.MediaSourceUtils.getMirrorTabId( + 'urn:x-org.chromium.media:source:tab:666')) + .toBe(666); + }); + }); + +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_clock.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_clock.js new file mode 100644 index 00000000000..dbb165cfb14 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_clock.js @@ -0,0 +1,391 @@ +// 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. + +goog.setTestOnly('mr.MockClock'); +goog.provide('mr.MockClock'); + +goog.require('mr.MockPromise'); + + +/** + * Class for unit testing code that uses setTimeout, clearTimeout, etc. + * @final + */ +mr.MockClock = class { + /** + * Installs the MockClock by overriding the global object's + * implementation of setTimeout, setInterval, clearTimeout and + * clearInterval. + */ + constructor() { + if (window.setTimeout !== mr.MockClock.REAL_SETTIMEOUT_) { + throw Error('MockClock already installed.'); + } + + /** + * List of times to fire, sorted in reverse order of when they + * will be executed. + * + * @type {!Array<mr.MockClock.Timeout_>} + * @private + */ + this.queue_ = []; + + /** + * The current simulated time in milliseconds. + * @type {number} + * @private + */ + this.nowMillis_ = 0; + + mr.MockClock.installedHere_ = Error('MockClock was installed here.'); + + window.setTimeout = this.setTimeout_.bind(this); + window.setInterval = this.setInterval_.bind(this); + window.setImmediate = this.setImmediate_.bind(this); + window.clearTimeout = this.clearTimeout_.bind(this); + window.clearInterval = this.clearTimeout_.bind(this); + Date.now = this.getCurrentTime_.bind(this); + } + + /** + * Removes the MockClock's hooks into the global object's functions + * and revert to their original values. + */ + uninstall() { + if (window.setTimeout === mr.MockClock.REAL_SETTIMEOUT_) { + throw Error('MockClock not installed.'); + } + + mr.MockClock.installedHere_ = null; + + window.setTimeout = mr.MockClock.REAL_SETTIMEOUT_; + window.setInterval = mr.MockClock.REAL_SETINTERVAL_; + window.setImmediate = mr.MockClock.REAL_SETIMMEDIATE_; + window.clearTimeout = mr.MockClock.REAL_CLEARTIMEOUT_; + window.clearInterval = mr.MockClock.REAL_CLEARINTERVAL_; + Date.now = mr.MockClock.REAL_DATENOW_; + } + + /** + * Restores this clock to the state it was in just after it was + * created. + */ + reset() { + this.queue_ = []; + this.nowMillis_ = 0; + } + + /** + * Increments the MockClock's time by a given number of + * milliseconds, running any functions that are now overdue. + * @param {number=} millis Number of milliseconds to increment the + * counter. If not specified, clock ticks 1 millisecond. + * @return {number} Current mock time in milliseconds. + */ + tick(millis = 1) { + const endTime = this.nowMillis_ + millis; + this.runFunctionsWithinRange_(endTime); + this.nowMillis_ = endTime; + return endTime; + } + + /** + * Ticks the clock until there are no more actions scheduled to run. + */ + flush() { + this.tick(Infinity); + } + + /** + * Takes a promise and then ticks the mock clock. If the promise + * successfully resolves, returns the value produced by the + * promise. If the promise is rejected, it throws the rejection as + * an exception. If the promise is not resolved at all, throws an + * exception. Also ticks the general clock by the specified amount. + * + * @param {!mr.MockPromise<T>} promise A promise that should be + * resolved after the mockClock is ticked for the given + * opt_millis. + * @param {number=} millis Number of milliseconds to increment the + * counter. If not specified, clock ticks 1 millisecond. + * @return {T} + * @template T + */ + tickPromise(promise, millis = 1) { + let value; + let error; + let resolved = false; + promise.then( + v => { + value = v; + resolved = true; + }, + e => { + error = e; + resolved = true; + }); + this.tick(millis); + if (!resolved) { + throw new Error( + 'Promise was expected to be resolved ' + + 'after mock clock tick.'); + } + if (error) { + throw error; + } + return value; + } + + /** + * Takes a promise and then ticks the mock clock. If the promise + * rejects, returns the error produced by the promise. If the + * promise is rejected, it throws the rejection as an exception. If + * the promise is not rejected at all, throws an exception. Also + * ticks the general clock by the specified amount. + * + * @param {!mr.MockPromise<T>} promise A promise that should be + * rejected after the mockClock is ticked for the given + * opt_millis. + * @param {number=} millis Number of milliseconds to increment the + * counter. If not specified, clock ticks 1 millisecond. + * @return {*} Error produced by the promise. + * @template T + */ + tickRejectingPromise(promise, millis = 1) { + let error; + let rejected = false; + promise.catch(e => { + error = e; + rejected = true; + }); + this.tick(millis); + if (!rejected) { + throw new Error( + 'Promise was expected to be rejected after mock clock tick.'); + } + return error; + } + + /** + * @return {number} The MockClock's current time in milliseconds. + * @private + */ + getCurrentTime_() { + return this.nowMillis_; + } + + /** + * Runs any function that is scheduled before a certain time. + * @param {number} endTime The latest time in the range, in + * milliseconds. + * @private + */ + runFunctionsWithinRange_(endTime) { + mr.MockPromise.callPendingHandlers(); + + // Repeatedly pop off the last item since the queue is always + // sorted. + while (this.queue_ && this.queue_.length && + this.queue_[this.queue_.length - 1].runAtMillis <= endTime) { + const timeout = this.queue_.pop(); + // Only move time forwards. + this.nowMillis_ = Math.max(this.nowMillis_, timeout.runAtMillis); + if (timeout.recurring) { + // Reschedule before calling the function so that if the + // function deletes the timeout, it's in the queue to be + // removed. + this.scheduleFunction_( + timeout.timeoutKey, timeout.funcToCall, timeout.millis, true); + } + timeout.funcToCall.call(undefined); + mr.MockPromise.callPendingHandlers(); + } + } + + /** + * Schedules a function to be run at a certain time. + * @param {number} timeoutKey The timeout key. + * @param {!Function} funcToCall The function to call. + * @param {number} millis The number of milliseconds to call it in. + * @param {boolean} recurring Whether to function call should recur. + * @private + */ + scheduleFunction_(timeoutKey, funcToCall, millis, recurring) { + const timeout = { + runAtMillis: this.nowMillis_ + millis, + funcToCall: funcToCall, + recurring: recurring, + timeoutKey: timeoutKey, + millis: millis, + }; + + // Insert a timer descriptor into a descending-order queue. + // + // Later-inserted duplicates appear at lower indices. For + // example, the asterisk in (5,4,*,3,2,1) would be the insertion + // point for 3. (The numbers here refer to timestamps.) + // + // Insertion of N items is quadratic, but unit tests are normally + // small, so scalability is not a primary issue. + // + // Since the queue is in reverse order (so we can pop rather than + // unshift), and later timers with the same time stamp should be + // executed later, we look for the element strictly greater than + // the one we are inserting. + let i; + for (i = this.queue_.length; i != 0; i--) { + if (this.queue_[i - 1].runAtMillis > timeout.runAtMillis) { + break; + } + this.queue_[i] = this.queue_[i - 1]; + } + this.queue_[i] = timeout; + } + + /** + * Schedules a function to be called after `millis` + * milliseconds. Mock implementation for setTimeout. + * @param {!Function} funcToCall The function to call. + * @param {number=} millis The number of milliseconds to call it + * after. + * @param {...*} args Arguments to pass to the function. + * @return {number} The number of timeouts created. + * @private + */ + setTimeout_(funcToCall, millis = 0, ...args) { + if (millis > mr.MockClock.MAX_INT_) { + throw Error(`Bad timeout value: ${millis}`); + } + this.scheduleFunction_( + mr.MockClock.nextId_, funcToCall.bind(undefined, ...args), millis, + false); + return mr.MockClock.nextId_++; + } + + /** + * Schedules a function to be called every `millis` milliseconds. + * Mock implementation for setInterval. + * @param {!Function} funcToCall The function to call. + * @param {number=} millis The number of milliseconds between calls. + * @param {...*} args Arguments to pass to the function. + * @return {number} The number of timeouts created. + * @private + */ + setInterval_(funcToCall, millis = 0, ...args) { + this.scheduleFunction_( + mr.MockClock.nextId_, funcToCall.bind(undefined, ...args), millis, + true); + return mr.MockClock.nextId_++; + } + + /** + * Schedules a function to be called immediately after the current JS + * execution. + * Mock implementation for setImmediate. + * @param {!Function} funcToCall The function to call. + * @param {...*} args Arguments to pass to the function. + * @return {number} The number of timeouts created. + * @private + */ + setImmediate_(funcToCall, ...args) { + return this.setTimeout_(funcToCall, 0, ...args); + } + + /** + * Clears a timeout. + * Mock implementation for clearTimeout and clearInterval. + * @param {number} timeoutKey The timeout key to clear. + * @private + */ + clearTimeout_(timeoutKey) { + const newQueue = + this.queue_.filter(timeout => timeout.timeoutKey != timeoutKey); + if (newQueue.length == this.queue_.length_) { + // The real versions of clearTimeout and clearInterval silently + // ignore invalid keys, but we hold ourselves to a higher + // standard :-) + throw Error('Invalid timeoutKey'); + } + this.queue_ = newQueue; + } +}; + + +/** + * ID to use for next timeout. Timeout IDs must never be reused, even + * across MockClock instances. + * @private {number} + */ +mr.MockClock.nextId_ = 0; + + +/** + * @private @const + */ +mr.MockClock.REAL_SETTIMEOUT_ = window.setTimeout; + + +/** + * @private @const + */ +mr.MockClock.REAL_SETINTERVAL_ = window.setInterval; + + +/** + * @private @const + */ +mr.MockClock.REAL_SETIMMEDIATE_ = window.setImmediate; + + +/** + * @private @const + */ +mr.MockClock.REAL_CLEARTIMEOUT_ = window.clearTimeout; + + +/** + * @private @const + */ +mr.MockClock.REAL_CLEARINTERVAL_ = window.clearInterval; + + +/** + * @private @const + */ +mr.MockClock.REAL_DATENOW_ = Date.now; + + +/** + * Maximum 32-bit signed integer. + * + * Timeouts over this time return immediately in many browsers, due to + * integer overflow. Such known browsers include Firefox, Chrome, and + * Safari, but not IE. + * + * @type {number} + * @private + */ +mr.MockClock.MAX_INT_ = 2147483647; + + +/** + * @typedef {{ + * runAtMillis: number, + * funcToCall: !Function, + * recurring: boolean, + * timeoutKey: number, + * millis: number, + * }} + * @private + */ +mr.MockClock.Timeout_; + + +/** + * Exception used to record where the current MockClock was created. Helpful + * for diagnosing unit tests that fail to uninstall their mock clocks. + * @private {Error} + */ +mr.MockClock.installedHere_ = null; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_promise.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_promise.js new file mode 100644 index 00000000000..511ddbdec0a --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_promise.js @@ -0,0 +1,602 @@ +// 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. + +goog.provide('mr.MockPromise'); +goog.setTestOnly('mr.MockPromise'); + + +/** + * Why does this class exist? + * + * Most of our unit tests that involve promises are written in "synchronous + * style". In this style, everything happens in one iteration of the JS event + * loop. With native promises, this style isn't possible, because a call like + * p.then(f) won't run f until at least the next event loop iteration, even if p + * is already resolved. + * + * When the tests were originally written, they relied on a particular + * interaction between goog.testing.MockClock and goog.Promise, where calling + * mockClock.tick() would force any code scheduled by goog.Promise to be + * executed immediately. Since then, the non-test code has been changed to use + * native promises, and the test code has been changed to use this class and + * mr.MockClock, but the same principle applies. + * + * The long-term plan is to convert the tests to use asynchronous style, and get + * rid of this class and mr.MockClock entirely. + * + * @template TYPE + * @final + */ +mr.MockPromise = class { + /** + * @param {function( + * function((TYPE|mr.MockPromise<TYPE>)=), + * function(*=)): void} resolver + */ + constructor(resolver) { + /** + * The internal state of this Promise. Either PENDING, FULFILLED, REJECTED, + * or BLOCKED. + * @private {mr.MockPromise.State_} + */ + this.state_ = mr.MockPromise.State_.PENDING; + + /** + * The settled result of the Promise. Immutable once set with either a + * fulfillment value or rejection reason. + * @private {*} + */ + this.result_ = undefined; + + /** + * The linked list of `onFulfilled` and `onRejected` callbacks + * added to this Promise by calls to {@code then()}. + * @private {!Array<!mr.MockPromise.CallbackEntry_>} + */ + this.callbackEntries_ = []; + + /** + * Whether the Promise is in the queue of Promises to execute. + * @private {boolean} + */ + this.executing_ = false; + + /** + * A boolean that is set if the Promise is rejected, and reset to false if + * an `onRejected` callback is invoked for the Promise (or one of its + * descendants). If the rejection is not handled before the next timestep, + * the rejection reason is passed to the unhandled rejection handler. + * @private {boolean} + */ + this.hadUnhandledRejection_ = false; + + try { + resolver.call( + null, + value => { + this.resolve_(mr.MockPromise.State_.FULFILLED, value); + }, + reason => { + try { + // Promise was rejected. Step up one call frame to see why. + if (reason instanceof Error) { + throw reason; + } else { + throw new Error('Promise rejected.'); + } + } catch (e) { + // Only thrown so browser dev tools can catch rejections of + // promises when the option to break on caught exceptions is + // activated. + } + this.resolve_(mr.MockPromise.State_.REJECTED, reason); + }); + } catch (e) { + this.resolve_(mr.MockPromise.State_.REJECTED, e); + } + } + + /** + * Replaces the native Promise class with this class. + */ + static install() { + if (window.Promise !== mr.MockPromise.origPromise_) { + throw Error('Error installing mr.MockPromise'); + } + if (mr.MockPromise.pendingHandlers_.length) { + throw Error('Expected no pending handlers.'); + } + window.Promise = mr.MockPromise; + mr.MockPromise.ignoreUnhandledRejections = false; + } + + /** + * Undoes the effect of calling install(). + */ + static uninstall() { + if (window.Promise !== mr.MockPromise) { + throw Error('mr.MockPromise not installed'); + } + if (mr.MockPromise.pendingHandlers_.length) { + console.warn('Discarding pending handlers.'); + mr.MockPromise.pendingHandlers_.length = 0; + // throw Error('Expected no pending handlers.'); + } + window.Promise = mr.MockPromise.origPromise_; + } + + /** + * @return {void} + */ + static callPendingHandlers() { + while (mr.MockPromise.pendingHandlers_.length) { + const handler = mr.MockPromise.pendingHandlers_.shift(); + handler(); + } + } + + /** + * @param {Function} onFulfilled + * @param {Function} onRejected + * @return {!mr.MockPromise.CallbackEntry_} + * @private + */ + static getCallbackEntry_(onFulfilled, onRejected) { + const entry = new mr.MockPromise.CallbackEntry_(); + entry.onFulfilled = onFulfilled; + entry.onRejected = onRejected; + return entry; + } + + /** + * @param {*=} opt_value + * @return {!mr.MockPromise} A new Promise that is immediately resolved + * with the given value. If the input value is already a mr.MockPromise, + * it will be returned immediately without creating a new instance. + */ + static resolve(opt_value) { + if (opt_value instanceof mr.MockPromise) { + // Avoid creating a new object if we already have a promise object + // of the correct type. + return opt_value; + } + + return new mr.MockPromise((resolve, reject) => { + resolve(opt_value); + }); + } + + /** + * @param {*=} opt_reason + * @return {!mr.MockPromise} A new Promise that is immediately rejected with the + * given reason. + */ + static reject(opt_reason) { + return new mr.MockPromise((resolve, reject) => { + reject(opt_reason); + }); + } + + /** + * @param {!Array} promises + * @return {!mr.MockPromise} A Promise that receives the result of the first + * Promise input to settle immediately after it settles. + */ + static race(promises) { + return new mr.MockPromise((resolve, reject) => { + if (!promises.length) { + resolve(undefined); + } + for (let i = 0, promise; i < promises.length; i++) { + promise = promises[i]; + mr.MockPromise.resolve(promise).then(resolve, reject); + } + }); + } + + /** + * @param {!Array} promises + * @return {!mr.MockPromise<!Array>} A Promise that receives a list of + * every fulfilled value once every input Promise is successfully + * fulfilled, or is rejected with the first rejection reason immediately + * after it is rejected. + */ + static all(promises) { + return new mr.MockPromise((resolve, reject) => { + let toFulfill = promises.length; + const values = []; + + if (!toFulfill) { + resolve(values); + return; + } + + const onFulfill = (index, value) => { + toFulfill--; + values[index] = value; + if (toFulfill == 0) { + resolve(values); + } + }; + + const onReject = reason => { + reject(reason); + }; + + for (let i = 0, promise; i < promises.length; i++) { + promise = promises[i]; + mr.MockPromise.resolve(promise).then(onFulfill.bind(null, i), onReject); + } + }); + } + + /** + * Adds callbacks that will operate on the result of the Promise, returning a + * new child Promise. + * + * If the Promise is fulfilled, the `onFulfilled` callback will be + * invoked with the fulfillment value as argument, and the child Promise will + * be fulfilled with the return value of the callback. If the callback throws + * an exception, the child Promise will be rejected with the thrown value + * instead. + * + * If the Promise is rejected, the `onRejected` callback will be invoked + * with the rejection reason as argument, and the child Promise will be + * resolved with the return value or rejected with the thrown value of the + * callback. + * + * @param {?(function(TYPE):?)=} onFulfilled A + * function that will be invoked with the fulfillment value if the Promise + * is fulfilled. + * @param {?(function(*): *)=} onRejected A function that will + * be invoked with the rejection reason if the Promise is rejected. + * @return {!mr.MockPromise} A new Promise that will receive the result of the + * callback. + * @override + */ + then(onFulfilled = null, onRejected = null) { + if (onFulfilled != null && typeof onFulfilled != 'function') { + throw Error('onFulfilled should be a function.'); + } + if (onRejected != null && typeof onRejected != 'function') { + throw Error('onRejected should be a function.'); + } + + return this.addChildPromise_(onFulfilled, onRejected); + } + + /** + * Adds a callback that will be invoked only if the Promise is rejected. This + * is equivalent to {@code then(null, onRejected)}. + * + * @param {function(*): *} onRejected A function that will be + * invoked with the rejection reason if the Promise is rejected. + * @return {!mr.MockPromise} A new Promise that will receive the result of the + * callback. + */ + catch(onRejected) { + return this.addChildPromise_(null, onRejected); + } + + /** + * Adds a callback entry to the current Promise, and schedules callback + * execution if the Promise has already been settled. + * + * @param {mr.MockPromise.CallbackEntry_} callbackEntry Record containing + * { + * @private + */ + addCallbackEntry_(callbackEntry) { + if (!this.hasEntry_() && + (this.state_ == mr.MockPromise.State_.FULFILLED || + this.state_ == mr.MockPromise.State_.REJECTED)) { + this.scheduleCallbacks_(); + } + this.queueEntry_(callbackEntry); + } + + /** + * Creates a child Promise and adds it to the callback entry list. The result + * of the child Promise is determined by the result of the `onFulfilled` + * or `onRejected` callbacks as specified in the Promise resolution + * procedure. + * + * @param {?function(TYPE): + * (RESULT|mr.MockPromise<RESULT>)} onFulfilled A callback that + * will be invoked if the Promise is fulfilled, or null. + * @param {?function(*): *} onRejected A callback that will be + * invoked if the Promise is rejected, or null. + * @return {!mr.MockPromise} The child Promise. + * @template RESULT + * @private + */ + addChildPromise_(onFulfilled, onRejected) { + /** @type {mr.MockPromise.CallbackEntry_} */ + const callbackEntry = mr.MockPromise.getCallbackEntry_(null, null, null); + + callbackEntry.child = new mr.MockPromise((resolve, reject) => { + // Invoke onFulfilled, or resolve with the parent's value if absent. + callbackEntry.onFulfilled = onFulfilled ? value => { + try { + const result = onFulfilled.call(null, value); + resolve(result); + } catch (err) { + reject(err); + } + } : resolve; + + // Invoke onRejected, or reject with the parent's reason if absent. + callbackEntry.onRejected = onRejected ? reason => { + try { + resolve(onRejected.call(null, reason)); + } catch (err) { + reject(err); + } + } : reject; + }); + + this.addCallbackEntry_(callbackEntry); + return callbackEntry.child; + } + + /** + * Unblocks the Promise and fulfills it with the given value. + * + * @param {TYPE} value + * @private + */ + unblockAndFulfill_(value) { + if (this.state_ != mr.MockPromise.State_.BLOCKED) { + throw Error('Expected state to be BLOCKED.'); + } + this.state_ = mr.MockPromise.State_.PENDING; + this.resolve_(mr.MockPromise.State_.FULFILLED, value); + } + + /** + * Unblocks the Promise and rejects it with the given rejection reason. + * + * @param {*} reason + * @private + */ + unblockAndReject_(reason) { + if (this.state_ != mr.MockPromise.State_.BLOCKED) { + throw Error('Expected state to be BLOCKED.'); + } + this.state_ = mr.MockPromise.State_.PENDING; + this.resolve_(mr.MockPromise.State_.REJECTED, reason); + } + + /** + * Attempts to resolve a Promise with a given resolution state and value. This + * is a no-op if the given Promise has already been resolved. + * + * If the given result is a Promise, the Promise will be settled with the same + * state and result as the Thenable once it is itself settled. + * + * If the given result is not a Promise, the Promise will be settled + * (fulfilled or rejected) with that result based on the given state. + * + * @param {mr.MockPromise.State_} state + * @param {*} x The result to apply to the Promise. + * @private + */ + resolve_(state, x) { + if (this.state_ != mr.MockPromise.State_.PENDING) { + return; + } + + if (this === x) { + state = mr.MockPromise.State_.REJECTED; + x = new TypeError('Promise cannot resolve to itself'); + } + + this.state_ = mr.MockPromise.State_.BLOCKED; + + if (x instanceof mr.MockPromise) { + x.addCallbackEntry_(mr.MockPromise.getCallbackEntry_( + this.unblockAndFulfill_.bind(this), + this.unblockAndReject_.bind(this))); + return; + } + + this.result_ = x; + this.state_ = state; + this.scheduleCallbacks_(); + + if (state == mr.MockPromise.State_.REJECTED) { + mr.MockPromise.addUnhandledRejection_(this, x); + } + } + + /** + * Executes the pending callbacks of a settled Promise after a timeout. + * @private + */ + scheduleCallbacks_() { + if (!this.executing_) { + this.executing_ = true; + mr.MockPromise.pendingHandlers_.push(this.executeCallbacks_.bind(this)); + } + } + + /** + * @return {boolean} Whether there are any pending callbacks queued. + * @private + */ + hasEntry_() { + return this.callbackEntries_.size > 0; + } + + /** + * @param {mr.MockPromise.CallbackEntry_} entry + * @private + */ + queueEntry_(entry) { + if (entry.onFulfilled == null) { + throw Error('entry.onFulfilled == null'); + } + this.callbackEntries_.push(entry); + } + + /** + * @return {mr.MockPromise.CallbackEntry_} entry + * @private + */ + popEntry_() { + const entry = this.callbackEntries_.shift() || null; + if (entry && entry.onFulfilled == null) { + throw Error('entry.onFulfulled == null'); + } + return entry; + } + + /** + * Executes all pending callbacks for this Promise. + * + * @private + */ + executeCallbacks_() { + let entry = null; + while (entry = this.popEntry_()) { + this.executeCallback_(entry, this.state_, this.result_); + } + this.executing_ = false; + } + + /** + * Executes a pending callback for this Promise. Invokes an + * `onFulfilled` or `onRejected` callback based on the settled + * state of the Promise. + * + * @param {!mr.MockPromise.CallbackEntry_} callbackEntry An entry containing + * the onFulfilled and/or onRejected callbacks for this step. + * @param {mr.MockPromise.State_} state The resolution status of the Promise, + * either FULFILLED or REJECTED. + * @param {*} result The settled result of the Promise. + * @private + */ + executeCallback_(callbackEntry, state, result) { + // Cancel an unhandled rejection if the then call had an onRejected. + if (state == mr.MockPromise.State_.REJECTED && callbackEntry.onRejected) { + this.hadUnhandledRejection_ = false; + } + + if (callbackEntry.child) { + mr.MockPromise.invokeCallback_(callbackEntry, state, result); + } else { + try { + mr.MockPromise.invokeCallback_(callbackEntry, state, result); + } catch (err) { + mr.MockPromise.handleRejection_(err); + } + } + } + + /** + * Executes the onFulfilled or onRejected callback for a callbackEntry. + * + * @param {!mr.MockPromise.CallbackEntry_} callbackEntry + * @param {mr.MockPromise.State_} state + * @param {*} result + * @private + */ + static invokeCallback_(callbackEntry, state, result) { + if (state == mr.MockPromise.State_.FULFILLED) { + callbackEntry.onFulfilled.call(null, result); + } else if (callbackEntry.onRejected) { + callbackEntry.onRejected.call(null, result); + } + } + + /** + * Marks this rejected Promise as unhandled. If no `onRejected` callback + * is called for this Promise before the `UNHANDLED_REJECTION_DELAY` + * expires, the reason will be passed to the unhandled rejection handler. The + * handler typically rethrows the rejection reason so that it becomes visible + * in + * the developer console. + * + * @param {!mr.MockPromise} promise The rejected Promise. + * @param {*} reason The Promise rejection reason. + * @private + */ + static addUnhandledRejection_(promise, reason) { + promise.hadUnhandledRejection_ = true; + mr.MockPromise.pendingHandlers_.push(() => { + if (promise.hadUnhandledRejection_) { + mr.MockPromise.handleRejection_(reason); + } + }); + } + + /** + * @param {*} reason + * @private + */ + static handleRejection_(reason) { + if (!mr.MockPromise.ignoreUnhandledRejections) { + throw reason; + } + } +}; + + +/** + * @type {boolean} + */ +mr.MockPromise.ignoreUnhandledRejections = false; + + + +/** + * @private @const + */ +mr.MockPromise.origPromise_ = Promise; + + +/** + * The possible internal states for a Promise. These states are not directly + * observable to external callers. + * @enum {number} + * @private + */ +mr.MockPromise.State_ = { + /** The Promise is waiting for resolution. */ + PENDING: 0, + + /** The Promise is blocked waiting for the result of another Thenable. */ + BLOCKED: 1, + + /** The Promise has been resolved with a fulfillment value. */ + FULFILLED: 2, + + /** The Promise has been resolved with a rejection reason. */ + REJECTED: 3 +}; + + +/** + * @private @const {!Array<function()>} + */ +mr.MockPromise.pendingHandlers_ = []; + + +/** + * Entries in the callback chain. Each call to `then` or + * `catch` creates an entry containing the + * functions that may be invoked once the Promise is settled. + * + * @private @final + */ +mr.MockPromise.CallbackEntry_ = class { + constructor() { + /** @type {?mr.MockPromise} */ + this.child = null; + /** @type {Function} */ + this.onFulfilled = null; + /** @type {Function} */ + this.onRejected = null; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_promise_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_promise_test.js new file mode 100644 index 00000000000..dc02a027eb3 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/mock_promise_test.js @@ -0,0 +1,850 @@ +// 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. + +goog.provide('mr.MockPromiseTest'); +goog.setTestOnly('mr.MockPromiseTest'); + +goog.require('mr.MockPromise'); + + +describe('mr.MockPromise', () => { + afterEach(() => { + mr.MockPromise.callPendingHandlers(); + }); + + // Simple shared objects used as test values. + const dummy = { + toString() { + return '[object dummy]'; + } + }; + const sentinel = { + toString() { + return '[object sentinel]'; + } + }; + + /** + * Dummy onfulfilled or onrejected function that should not be called. + * + * @param {*} result The result passed into the callback. + */ + function shouldNotCall(result) { + fail('This should not have been called (result: ' + String(result) + ')'); + } + + function resolveSoon(value) { + let resolveFunc; + const promise = new mr.MockPromise((resolve, reject) => { + resolveFunc = resolve.bind(null, value); + }); + return [promise, resolveFunc]; + } + + function rejectSoon(value) { + let rejectFunc; + const promise = new mr.MockPromise((resolve, reject) => { + rejectFunc = reject.bind(null, value); + }); + return [promise, rejectFunc]; + } + + // A trivial expectation test to suppress Jasmine warnings. + function noExpectations() { + expect(true).toBe(true); + } + + it('testThenIsFulfilled', () => { + let timesCalled = 0; + + const p = new mr.MockPromise((resolve, reject) => { + resolve(sentinel); + }); + p.then(value => { + timesCalled++; + expect(value).toBe(sentinel); + }); + + expect(timesCalled).toBe(0); + + mr.MockPromise.callPendingHandlers(); + expect(timesCalled).toBe(1); + }); + + it('testThenIsRejected', () => { + let timesCalled = 0; + + const p = mr.MockPromise.reject(sentinel); + p.then(shouldNotCall, value => { + timesCalled++; + expect(value).toBe(sentinel); + }); + + expect(timesCalled).toBe(0); + + mr.MockPromise.callPendingHandlers(); + expect(timesCalled).toBe(1); + }); + + it('testThenAsserts', () => { + const p = mr.MockPromise.resolve(); + + expect(() => { + p.then({}); + }).toThrowError(/onFulfilled should be a function./); + + expect(() => { + p.then(() => {}, {}); + }).toThrowError(/onRejected should be a function./); + }); + + it('testOptionalOnFulfilled', done => { + mr.MockPromise.resolve(sentinel) + .then(null, null) + .then(null, shouldNotCall) + .then(value => { + expect(value).toBe(sentinel); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testOptionalOnRejected', done => { + mr.MockPromise.reject(sentinel) + .then(null, null) + .then(shouldNotCall) + .then(null, reason => { + expect(reason).toBe(sentinel); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testMultipleResolves', () => { + let timesCalled = 0; + let resolvePromise; + + const p = new mr.MockPromise((resolve, reject) => { + resolvePromise = resolve; + resolve('foo'); + resolve('bar'); + }); + + p.then(value => { + timesCalled++; + expect(timesCalled).toBe(1); + }); + + mr.MockPromise.callPendingHandlers(); + + resolvePromise('baz'); + expect(timesCalled).toBe(1); + }); + + it('testMultipleRejects', () => { + let timesCalled = 0; + let rejectPromise; + + const p = new mr.MockPromise((resolve, reject) => { + rejectPromise = reject; + reject('foo'); + reject('bar'); + }); + + p.then(shouldNotCall, value => { + timesCalled++; + expect(timesCalled).toBe(1); + }); + + mr.MockPromise.callPendingHandlers(); + + rejectPromise('baz'); + expect(timesCalled).toBe(1); + }); + + it('testResolveWithPromise', () => { + let resolveBlocker; + let hasFulfilled = false; + const blocker = new mr.MockPromise((resolve, reject) => { + resolveBlocker = resolve; + }); + + const p = mr.MockPromise.resolve(blocker); + p.then(value => { + hasFulfilled = true; + expect(value).toBe(sentinel); + }, shouldNotCall); + + expect(hasFulfilled).toBe(false); + resolveBlocker(sentinel); + + mr.MockPromise.callPendingHandlers(); + expect(hasFulfilled).toBe(true); + }); + + it('testResolveWithRejectedPromise', () => { + let rejectBlocker; + let hasRejected = false; + const blocker = new mr.MockPromise((resolve, reject) => { + rejectBlocker = reject; + }); + + const p = mr.MockPromise.resolve(blocker); + const child = p.then(shouldNotCall, reason => { + hasRejected = true; + expect(reason).toBe(sentinel); + }); + + expect(hasRejected).toBe(false); + rejectBlocker(sentinel); + + mr.MockPromise.callPendingHandlers(); + expect(hasRejected).toBe(true); + }); + + it('testRejectWithPromise', () => { + let resolveBlocker; + let hasFulfilled = false; + const blocker = new mr.MockPromise((resolve, reject) => { + resolveBlocker = resolve; + }); + + const p = mr.MockPromise.reject(blocker); + const child = p.then(value => { + hasFulfilled = true; + expect(value).toBe(sentinel); + }, shouldNotCall); + + expect(hasFulfilled).toBe(false); + resolveBlocker(sentinel); + + mr.MockPromise.callPendingHandlers(); + expect(hasFulfilled).toBe(true); + }); + + it('testRejectWithRejectedPromise', () => { + let rejectBlocker; + let hasRejected = false; + const blocker = new mr.MockPromise((resolve, reject) => { + rejectBlocker = reject; + }); + + const p = mr.MockPromise.reject(blocker); + const child = p.then(shouldNotCall, reason => { + hasRejected = true; + expect(reason).toBe(sentinel); + }); + + expect(hasRejected).toBe(false); + rejectBlocker(sentinel); + + mr.MockPromise.callPendingHandlers(); + expect(hasRejected).toBe(true); + }); + + it('testResolveAndReject', () => { + let onFulfilledCalled = false; + let onRejectedCalled = false; + const p = new mr.MockPromise((resolve, reject) => { + resolve(); + reject(); + }); + + p.then( + () => { + onFulfilledCalled = true; + }, + () => { + onRejectedCalled = true; + }); + + mr.MockPromise.callPendingHandlers(); + expect(onFulfilledCalled).toBe(true); + expect(onRejectedCalled).toBe(false); + }); + + it('testResolveWithSelfRejects', done => { + let r; + const p = new mr.MockPromise(resolve => { + r = resolve; + }); + r(p); + p.then(shouldNotCall, e => { + expect(e.message).toBe('Promise cannot resolve to itself'); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testResolveWithObjectStringResolves', done => { + mr.MockPromise.resolve('[object Object]').then(v => { + expect(v).toBe('[object Object]'); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRejectAndResolve', done => { + noExpectations(); + new mr + .MockPromise((resolve, reject) => { + reject(); + resolve(); + }) + .then(shouldNotCall, done); + mr.MockPromise.callPendingHandlers(); + }); + + it('testThenReturnsBeforeCallbackWithFulfill', done => { + let thenHasReturned = false; + const p = mr.MockPromise.resolve(); + + p.then(() => { + expect(thenHasReturned).toBe(true); + done(); + }); + thenHasReturned = true; + + mr.MockPromise.callPendingHandlers(); + }); + + it('testThenReturnsBeforeCallbackWithReject', done => { + let thenHasReturned = false; + const p = mr.MockPromise.reject(); + + const child = p.then(shouldNotCall, () => { + expect(thenHasReturned).toBe(true); + done(); + }); + thenHasReturned = true; + + mr.MockPromise.callPendingHandlers(); + }); + + it('testResolutionOrder', () => { + const callbacks = []; + mr.MockPromise.resolve() + .then( + () => { + callbacks.push(1); + }, + shouldNotCall) + .then( + () => { + callbacks.push(2); + }, + shouldNotCall) + .then(() => { + callbacks.push(3); + }, shouldNotCall); + + mr.MockPromise.callPendingHandlers(); + expect(callbacks).toEqual([1, 2, 3]); + }); + + it('testResolutionOrderWithThrow', done => { + const callbacks = []; + const p = mr.MockPromise.resolve(); + + p.then(() => { + callbacks.push(1); + }, shouldNotCall); + const child = p.then(() => { + callbacks.push(2); + throw Error(); + }, shouldNotCall); + + child.then(shouldNotCall, () => { + // The parent callbacks should be evaluated before the child. + callbacks.push(4); + }); + + p.then(() => { + callbacks.push(3); + }, shouldNotCall); + + child.then(shouldNotCall, () => { + callbacks.push(5); + expect(callbacks).toEqual([1, 2, 3, 4, 5]); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testResolutionOrderWithNestedThen', done => { + const callbacks = []; + const promise = new mr.MockPromise((resolve, reject) => { + const p = mr.MockPromise.resolve(); + + p.then(() => { + callbacks.push(1); + p.then(() => { + callbacks.push(3); + resolve(); + }); + }); + p.then(() => { + callbacks.push(2); + }); + }); + + promise.then(() => { + expect(callbacks).toEqual([1, 2, 3]); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRejectionOrder', () => { + const callbacks = []; + const p = mr.MockPromise.reject(); + + p.then(shouldNotCall, () => { + callbacks.push(1); + }); + p.then(shouldNotCall, () => { + callbacks.push(2); + }); + p.then(shouldNotCall, () => { + callbacks.push(3); + }); + + mr.MockPromise.callPendingHandlers(); + expect(callbacks).toEqual([1, 2, 3]); + }); + + it('testRejectionOrderWithThrow', () => { + const callbacks = []; + const p = mr.MockPromise.reject(); + + p.then(shouldNotCall, () => { + callbacks.push(1); + }); + p.then(shouldNotCall, () => { + callbacks.push(2); + throw Error(); + }).catch(() => {}); + p.then(shouldNotCall, () => { + callbacks.push(3); + }); + + mr.MockPromise.callPendingHandlers(); + expect(callbacks).toEqual([1, 2, 3]); + }); + + it('testRejectionOrderWithNestedThen', done => { + const callbacks = []; + const promise = new mr.MockPromise((resolve, reject) => { + + const p = mr.MockPromise.reject(); + + p.then(shouldNotCall, () => { + callbacks.push(1); + p.then(shouldNotCall, () => { + callbacks.push(3); + resolve(); + }); + }); + p.then(shouldNotCall, () => { + callbacks.push(2); + }); + }); + + promise.then(() => { + expect(callbacks).toEqual([1, 2, 3]); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testBranching', () => { + const p = mr.MockPromise.resolve(2); + let branchesResolved = 0; + + const branch1 = p.then(value => { + expect(value).toBe(2); + return value / 2; + }).then(value => { + expect(value).toBe(1); + ++branchesResolved; + }); + + const branch2 = p.then(value => { + expect(value).toBe(2); + throw value + 1; + }).then(shouldNotCall, reason => { + expect(reason).toBe(3); + ++branchesResolved; + }); + + const branch3 = p.then(value => { + expect(value).toBe(2); + return value * 2; + }).then(value => { + expect(value).toBe(4); + ++branchesResolved; + }); + + mr.MockPromise.all([branch1, branch2, branch3]); + mr.MockPromise.callPendingHandlers(); + expect(branchesResolved).toBe(3); + }); + + it('testThenReturnsPromise', () => { + const parent = mr.MockPromise.resolve(); + const child = parent.then(); + + expect(child instanceof mr.MockPromise).toBe(true); + expect(child).not.toEqual(parent); + }); + + it('testBlockingPromise', () => { + const p = mr.MockPromise.resolve(); + let wasFulfilled = false; + let wasRejected = false; + + const p2 = p.then(() => new mr.MockPromise((resolve, reject) => {})); + + p2.then( + () => { + wasFulfilled = true; + }, + () => { + wasRejected = true; + }); + + mr.MockPromise.callPendingHandlers(); + expect(wasFulfilled).toBe(false); + expect(wasRejected).toBe(false); + }); + + it('testBlockingPromiseFulfilled', done => { + let resolveBlockingPromise; + const blockingPromise = new mr.MockPromise((resolve, reject) => { + resolveBlockingPromise = resolve; + }); + + const p = mr.MockPromise.resolve(dummy); + const p2 = p.then(value => blockingPromise); + + p2.then(value => { + expect(value).toBe(sentinel); + done(); + }); + resolveBlockingPromise(sentinel); + mr.MockPromise.callPendingHandlers(); + }); + + it('testBlockingPromiseRejected', done => { + let rejectBlockingPromise; + const blockingPromise = new mr.MockPromise((resolve, reject) => { + rejectBlockingPromise = reject; + }); + + const p = mr.MockPromise.resolve(blockingPromise); + + p.then(shouldNotCall, reason => { + expect(reason).toBe(sentinel); + done(); + }); + rejectBlockingPromise(sentinel); + mr.MockPromise.callPendingHandlers(); + }); + + it('testCatch', done => { + let catchCalled = false; + mr.MockPromise.reject() + .catch(reason => { + catchCalled = true; + return sentinel; + }) + .then(value => { + expect(catchCalled).toBe(true); + expect(value).toBe(sentinel); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRaceWithEmptyList', done => { + mr.MockPromise.race([]).then(value => { + expect(value).toBe(undefined); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRaceWithFulfill', done => { + const [a, resolveA] = resolveSoon('a'); + const [b, resolveB] = resolveSoon('b'); + const [c, resolveC] = resolveSoon('c'); + const [d, resolveD] = resolveSoon('d'); + + mr.MockPromise.race([a, b, c, d]) + .then(value => { + expect(value).toBe('c'); + // Return the slowest input promise to wait for it to complete. + return a; + }) + .then(value => { + expect(value).toBe('a'); + done(); + }); + resolveC(); + resolveD(); + resolveB(); + resolveA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRaceWithNonThenable', done => { + const [a, resolveA] = resolveSoon('a'); + const b = 'b'; + const [c, resolveC] = resolveSoon('c'); + const [d, resolveD] = resolveSoon('d'); + + mr.MockPromise.race([a, b, c, d]) + .then(value => { + expect(value).toBe('b'); + // Return the slowest input promise to wait for it to complete. + return a; + }) + .then(value => { + expect(value).toBe('a'); + done(); + }); + resolveC(); + resolveD(); + resolveA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRaceWithFalseyNonThenable', done => { + const [a, resolveA] = resolveSoon('a'); + const b = 0; + const [c, resolveC] = resolveSoon('c'); + const [d, resolveD] = resolveSoon('d'); + + mr.MockPromise.race([a, b, c, d]) + .then(value => { + expect(value).toBe(0); + // Return the slowest input promise to wait for it to complete. + return a; + }) + .then(value => { + expect(value).toBe('a'); + done(); + }); + resolveC(); + resolveD(); + resolveA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRaceWithFulfilledBeforeNonThenable', done => { + const [a, resolveA] = resolveSoon('a'); + const b = mr.MockPromise.resolve('b'); + const c = 'c'; + const [d, resolveD] = resolveSoon('d'); + + mr.MockPromise.race([a, b, c, d]) + .then(value => { + expect(value).toBe('b'); + // Return the slowest input promise to wait for it to complete. + return a; + }) + .then(value => { + expect(value).toBe('a'); + done(); + }); + resolveD(); + resolveA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testRaceWithReject', done => { + const [a, rejectA] = rejectSoon('rejected-a', 40); + const [b, rejectB] = rejectSoon('rejected-b', 30); + const [c, rejectC] = rejectSoon('rejected-c', 10); + const [d, rejectD] = rejectSoon('rejected-d', 20); + + mr.MockPromise.race([a, b, c, d]) + .then( + shouldNotCall, + value => { + expect(value).toBe('rejected-c'); + return a; + }) + .then(shouldNotCall, reason => { + expect(reason).toBe('rejected-a'); + done(); + }); + rejectC(); + rejectD(); + rejectB(); + rejectA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testAllWithEmptyList', done => { + mr.MockPromise.all([]).then(value => { + expect(value).toEqual([]); + done(); + }); + mr.MockPromise.callPendingHandlers(); + }); + + it('testAllWithFulfill', done => { + const [a, resolveA] = resolveSoon('a'); + const [b, resolveB] = resolveSoon('b'); + const [c, resolveC] = resolveSoon('c'); + const [d, resolveD] = resolveSoon('d'); + // Test a falsey value. + const [z, resolveZ] = resolveSoon(0); + + mr.MockPromise.all([a, b, c, d, z]).then(value => { + expect(value).toEqual(['a', 'b', 'c', 'd', 0]); + done(); + }); + resolveC(); + resolveD(); + resolveB(); + resolveZ(); + resolveA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testAllWithNonThenable', done => { + const [a, resolveA] = resolveSoon('a'); + const b = 'b'; + const [c, resolveC] = resolveSoon('c'); + const [d, resolveD] = resolveSoon('d'); + // Test a falsey value. + const z = 0; + + mr.MockPromise.all([a, b, c, d, z]).then(value => { + expect(value).toEqual(['a', 'b', 'c', 'd', 0]); + done(); + }); + resolveC(); + resolveD(); + resolveA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testAllWithReject', done => { + const [a, resolveA] = resolveSoon('a'); + const [b, rejectB] = rejectSoon('rejected-b'); + const [c, resolveC] = resolveSoon('c'); + const [d, resolveD] = resolveSoon('d'); + + mr.MockPromise.all([a, b, c, d]) + .then( + shouldNotCall, + reason => { + expect(reason).toBe('rejected-b'); + return a; + }) + .then(value => { + expect(value).toBe('a'); + done(); + }); + resolveC(); + resolveD(); + rejectB(); + resolveA(); + mr.MockPromise.callPendingHandlers(); + }); + + it('testMockClock', () => { + let resolveA; + let resolveB; + const calls = []; + + const p = new mr.MockPromise((resolve, reject) => { + resolveA = resolve; + }); + + p.then(value => { + expect(value).toBe(sentinel); + calls.push('then'); + }); + + const fulfilledChild = p.then(value => { + expect(value).toBe(sentinel); + return mr.MockPromise.resolve(1); + }).then(value => { + expect(value).toBe(1); + calls.push('fulfilledChild'); + + }); + + const rejectedChild = p.then(value => { + expect(value).toBe(sentinel); + return mr.MockPromise.reject(2); + }).then(shouldNotCall, reason => { + expect(reason).toBe(2); + calls.push('rejectedChild'); + }); + + const unresolvedChild = p.then(value => { + expect(value).toBe(sentinel); + return new mr.MockPromise(r => { + resolveB = r; + }); + }).then(value => { + expect(value).toBe(3); + calls.push('unresolvedChild'); + }); + + resolveA(sentinel); + expect(calls).toEqual([]); + + mr.MockPromise.callPendingHandlers(); + expect(calls).toEqual(['then', 'fulfilledChild', 'rejectedChild']); + + resolveB(3); + expect(calls).toEqual(['then', 'fulfilledChild', 'rejectedChild']); + + mr.MockPromise.callPendingHandlers(); + expect(calls).toEqual( + ['then', 'fulfilledChild', 'rejectedChild', 'unresolvedChild']); + }); + + it('testHandledRejection', () => { + noExpectations(); + mr.MockPromise.reject(sentinel).then(shouldNotCall, reason => {}); + mr.MockPromise.callPendingHandlers(); + }); + + it('testUnhandledRejection1', () => { + mr.MockPromise.reject(sentinel); + expect(mr.MockPromise.callPendingHandlers).toThrow(); + }); + + it('testUnhandledRejection2', () => { + mr.MockPromise.reject(sentinel).then(shouldNotCall); + expect(mr.MockPromise.callPendingHandlers).toThrow(); + }); + + it('testUnhandledThrow', () => { + mr.MockPromise.resolve().then(() => { + throw sentinel; + }); + expect(mr.MockPromise.callPendingHandlers).toThrow(); + }); + + it('testUnhandledBlockingRejection', () => { + const blocker = mr.MockPromise.reject(sentinel); + mr.MockPromise.resolve(blocker); + expect(mr.MockPromise.callPendingHandlers).toThrow(); + }); + + it('testHandledBlockingRejection', () => { + noExpectations(); + const blocker = mr.MockPromise.reject(sentinel); + mr.MockPromise.resolve(blocker).then(shouldNotCall, reason => {}); + mr.MockPromise.callPendingHandlers(); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/mojo_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/mojo_utils.js new file mode 100644 index 00000000000..f9d98798f8a --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/mojo_utils.js @@ -0,0 +1,63 @@ +// 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. + +goog.module('mr.MojoUtils'); +goog.module.declareLegacyNamespace(); + + +/** + * Converts a mojo.Origin object to a string. + * @param {!mojo.Origin} origin + * @return {string} + */ +exports.mojoOriginToString = function(origin) { + if (origin.unique) { + return ''; + } else { + return `${origin.scheme}:\/\/${origin.host} + ${origin.port ? `:${origin.port}` : ''}/`; + } +}; + + +/** + * Converts an origin string to a mojo.Origin object. + * @param {string} origin + * @return {!mojo.Origin} + */ +exports.stringToMojoOrigin = function(origin) { + const url = new URL(origin); + const mojoOrigin = {}; + mojoOrigin.scheme = url.protocol.replace(':', ''); + mojoOrigin.host = url.hostname; + var port = url.port ? Number.parseInt(url.port, 10) : 0; + switch (mojoOrigin.scheme) { + case 'http': + mojoOrigin.port = port || 80; + break; + case 'https': + mojoOrigin.port = port || 443; + break; + default: + throw new Error('Scheme must be http or https'); + } + mojoOrigin.suborigin = ''; + return new mojo.Origin(mojoOrigin); +}; + +/** + * @param {?mojo.TimeDelta} timeDelta + * @return {number} + */ +exports.timeDeltaToSeconds = function(timeDelta) { + return timeDelta ? timeDelta.microseconds / 1000000 : 0; +}; + +/** + * @param {number} seconds + * @return {!mojo.TimeDelta} + */ +exports.secondsToTimeDelta = function(seconds) { + return new mojo.TimeDelta({microseconds: Math.floor(seconds * 1000000)}); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/object_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/object_utils.js new file mode 100644 index 00000000000..699c9f0be27 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/object_utils.js @@ -0,0 +1,31 @@ +// 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. + +/** + * @fileoverview Utility methods for Objects. + */ + +goog.provide('mr.ObjectUtils'); + +mr.ObjectUtils = class { + /** + * Gets the value in an Object with the given list of keys by traversing the + * objects with them. + * @param {!Object} obj + * @param {...string} path + * @return {*} The value in the object with the given path, or undefined if + * it does not exist due to missing either one of the intermediate keys + * or the final key. + */ + static getPath(obj, ...path) { + let value = obj; + for (const key of path) { + if (value == undefined || typeof value != 'object') { + return undefined; + } + value = value[key]; + } + return value; + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/object_utils_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/object_utils_test.js new file mode 100644 index 00000000000..253c6142f4e --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/object_utils_test.js @@ -0,0 +1,33 @@ +// 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. + +goog.setTestOnly('object_utils_test'); + +goog.require('mr.ObjectUtils'); + +describe('mr.ObjectUtils test', () => { + const thudObj = {thud: 2}; + const obj = {foo: {bar: {xyzzy: null, baz: {qux: 1, corge: thudObj}}}}; + + it('getPath returns undefined for nonexistent path', () => { + expect(mr.ObjectUtils.getPath(obj, 'foo', 'bar', 'nonexistent')) + .toBeUndefined(); + expect( + mr.ObjectUtils.getPath(obj, 'foo', 'bar', 'baz', 'qux', 'nonexistent')) + .toBeUndefined(); + expect(mr.ObjectUtils.getPath(obj, 'foo', 'bar', 'xyzzy', 'nonexistent')) + .toBeUndefined(); + }); + + it('getPath returns value', () => { + expect(mr.ObjectUtils.getPath(obj, 'foo', 'bar', 'baz', 'qux')).toBe(1); + expect(mr.ObjectUtils.getPath(obj, 'foo', 'bar', 'baz', 'corge')) + .toBe(thudObj); + expect(mr.ObjectUtils.getPath(obj, 'foo', 'bar', 'xyzzy')).toBeNull(); + }); + + it('getPath returns itself if no path provided', () => { + expect(mr.ObjectUtils.getPath(obj)).toBe(obj); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/platform_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/platform_utils.js new file mode 100644 index 00000000000..26828b03848 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/platform_utils.js @@ -0,0 +1,62 @@ +// 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. + +/** + * @fileoverview Platform related utilities methods. + + */ + +goog.provide('mr.PlatformUtils'); + + +/** + * Returns true if the extension is running on Windows and Windows 8 or + * newer. + * + * @return {boolean} Whether the extension is running on Windows 8 or + * newer. + */ +mr.PlatformUtils.isWindows8OrNewer = function() { + // See MSDN for different windows versions: + // http://msdn.microsoft.com/en-us/library/ms537503(v=vs.85).aspx + const [, version] = navigator.userAgent.match(/Windows NT (\d+.\d+)/) || []; + + // Windows 8 has version number 6.2. + return parseFloat(version) >= 6.2; +}; + + +/** + * Enumeration of possible current operating systems. + * @enum {string} + */ +mr.PlatformUtils.OS = { + CHROMEOS: 'ChromeOS', + WINDOWS: 'Windows', + MAC: 'Mac', + LINUX: 'Linux', + OTHER: 'Other' +}; + + +/** + * Returns the current OS. + * @return {!mr.PlatformUtils.OS} + */ +mr.PlatformUtils.getCurrentOS = function() { + const userAgent = navigator.userAgent; + if (userAgent.includes('CrOS')) { + return mr.PlatformUtils.OS.CHROMEOS; + } + if (userAgent.includes('Windows')) { + return mr.PlatformUtils.OS.WINDOWS; + } + if (userAgent.includes('Macintosh')) { + return mr.PlatformUtils.OS.MAC; + } + if (userAgent.includes('Linux')) { + return mr.PlatformUtils.OS.LINUX; + } + return mr.PlatformUtils.OS.OTHER; +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/promise_resolver.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/promise_resolver.js new file mode 100644 index 00000000000..f7645339199 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/promise_resolver.js @@ -0,0 +1,54 @@ +// 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. + +/** + + */ +goog.module('mr.PromiseResolver'); +goog.module.declareLegacyNamespace(); + + +/** + * Wrapper around a Promise that allows the Promise to be resolved by + * calling a method. + * + * @template T + */ +exports = class { + constructor() { + /** + * @private {function(T)} + */ + this.resolveFunc_; + + /** + * @private {function(*)} + */ + this.rejectFunc_; + + /** + * @const {!Promise<T>} + */ + this.promise = new Promise((resolve, reject) => { + this.resolveFunc_ = resolve; + this.rejectFunc_ = reject; + }); + } + + /** + * Resolves the promise. + * @param {T} value + */ + resolve(value) { + this.resolveFunc_(value); + } + + /** + * Rejects the promise. + * @param {*} reason + */ + reject(reason) { + this.rejectFunc_(reason); + } +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/promise_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/promise_utils.js new file mode 100644 index 00000000000..0dcd2e23b12 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/promise_utils.js @@ -0,0 +1,36 @@ +// 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. + +/** + * @fileoverview Utility functions for dealing with promises. + */ +goog.module('mr.PromiseUtils'); +goog.module.declareLegacyNamespace(); + + +/** + * Given a list of promises, waits for all promises to settle, and produces a + * list indicating whether each input promise was fulfilled (i.e. resolved) or + * rejected. + * + * Each object in the output contains two fields: the |fulfilled| field is true + * if the corresponding input promise was resolved, or false if it was rejected. + * When |fulfilled| is true, the |value| field contains the value with which the + * promise was resolved. Otherwise, the |reason| field contains the reason with + * which the promise was rejected. + * + * @param {!Array<!Promise<T>>} promises + * @return {!Promise<!Array<{ + * fulfilled: boolean, + * value: (T | undefined), + * reason: * + * }>>} + * @template T + */ +exports.allSettled = function(promises) { + return Promise.all(promises.map( + promise => promise.then( + value => ({fulfilled: true, value: value}), + reason => ({fulfilled: false, reason: reason})))); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/sha1.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/sha1.js new file mode 100644 index 00000000000..8b537c9f02e --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/sha1.js @@ -0,0 +1,239 @@ +// 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. + +goog.module('mr.Sha1'); + +const BLOCK_SIZE = 512 / 8; + +/** + * SHA-1 cryptographic hash constructor. + * @final + */ +class Sha1 { + constructor() { + /** + * Holds the previous values of accumulated variables a-e in the compress_ + * function. + * @private @const {!Array<number>} + */ + this.chain_ = []; + + /** + * A buffer holding the partially computed hash result. + * @private @const {!Array<number>} + */ + this.buf_ = []; + + /** + * An array of 80 bytes, each a part of the message to be hashed. Referred + * to as the message schedule in the docs. + * @private @const {!Array<number>} + */ + this.W_ = []; + + /** + * Contains data needed to pad messages less than 64 bytes. + * @private @const {!Array<number>} + */ + this.pad_ = []; + + this.pad_[0] = 128; + for (let i = 1; i < BLOCK_SIZE; ++i) { + this.pad_[i] = 0; + } + + /** + * @private {number} + */ + this.inbuf_ = 0; + + /** + * @private {number} + */ + this.total_ = 0; + + this.reset(); + } + + /** + * Resets the internal accumulator. + */ + reset() { + this.chain_[0] = 0x67452301; + this.chain_[1] = 0xefcdab89; + this.chain_[2] = 0x98badcfe; + this.chain_[3] = 0x10325476; + this.chain_[4] = 0xc3d2e1f0; + + this.inbuf_ = 0; + this.total_ = 0; + } + + /** + * Internal compress helper function. + * @param {!Array<number>|string} buf Block to compress. + * @param {number=} offset Offset of the block in the buffer. + * @private + */ + compress_(buf, offset = 0) { + let W = this.W_; + + // get 16 big endian words + if (typeof buf === 'string') { + for (let i = 0; i < 16; i++) { + W[i] = (buf.charCodeAt(offset) << 24) | + (buf.charCodeAt(offset + 1) << 16) | + (buf.charCodeAt(offset + 2) << 8) | (buf.charCodeAt(offset + 3)); + offset += 4; + } + } else { + for (let i = 0; i < 16; i++) { + W[i] = (buf[offset] << 24) | (buf[offset + 1] << 16) | + (buf[offset + 2] << 8) | (buf[offset + 3]); + offset += 4; + } + } + + // expand to 80 words + for (let i = 16; i < 80; i++) { + let t = W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16]; + W[i] = ((t << 1) | (t >>> 31)) & 0xffffffff; + } + + let a = this.chain_[0]; + let b = this.chain_[1]; + let c = this.chain_[2]; + let d = this.chain_[3]; + let e = this.chain_[4]; + let f, k; + + for (let i = 0; i < 80; i++) { + if (i < 40) { + if (i < 20) { + f = d ^ (b & (c ^ d)); + k = 0x5a827999; + } else { + f = b ^ c ^ d; + k = 0x6ed9eba1; + } + } else { + if (i < 60) { + f = (b & c) | (d & (b | c)); + k = 0x8f1bbcdc; + } else { + f = b ^ c ^ d; + k = 0xca62c1d6; + } + } + + let t = (((a << 5) | (a >>> 27)) + f + e + k + W[i]) & 0xffffffff; + e = d; + d = c; + c = ((b << 30) | (b >>> 2)) & 0xffffffff; + b = a; + a = t; + } + + this.chain_[0] = (this.chain_[0] + a) & 0xffffffff; + this.chain_[1] = (this.chain_[1] + b) & 0xffffffff; + this.chain_[2] = (this.chain_[2] + c) & 0xffffffff; + this.chain_[3] = (this.chain_[3] + d) & 0xffffffff; + this.chain_[4] = (this.chain_[4] + e) & 0xffffffff; + } + + /** + * Adds a string (must only contain 8-bit, i.e., Latin1 characters) + * to the internal accumulator. + * + * @param {string|!Array<number>} bytes Data used for the update. + * @param {number=} length + */ + update(bytes, length = bytes.length) { + let lengthMinusBlock = length - BLOCK_SIZE; + let n = 0; + // Using local instead of member variables gives ~5% speedup on Firefox 16. + let buf = this.buf_; + let inbuf = this.inbuf_; + + // The outer while loop should execute at most twice. + while (n < length) { + // When we have no data in the block to top up, we can directly process + // the input buffer (assuming it contains sufficient data). This gives + // ~25% speedup on Chrome 23 and ~15% speedup on Firefox 16, but requires + // that the data is provided in large chunks (or in multiples of 64 + // bytes). + if (inbuf == 0) { + while (n <= lengthMinusBlock) { + this.compress_(bytes, n); + n += BLOCK_SIZE; + } + } + + if (typeof bytes === 'string') { + while (n < length) { + buf[inbuf] = bytes.charCodeAt(n); + ++inbuf; + ++n; + if (inbuf == BLOCK_SIZE) { + this.compress_(buf); + inbuf = 0; + // Jump to the outer loop so we use the full-block optimization. + break; + } + } + } else { + while (n < length) { + buf[inbuf] = bytes[n]; + ++inbuf; + ++n; + if (inbuf == BLOCK_SIZE) { + this.compress_(buf); + inbuf = 0; + // Jump to the outer loop so we use the full-block optimization. + break; + } + } + } + } + + this.inbuf_ = inbuf; + this.total_ += length; + } + + /** + * @return {!Array<number>} The finalized hash computed + * from the internal accumulator. + */ + digest() { + let digest = []; + let totalBits = this.total_ * 8; + + // Add pad 0x80 0x00*. + if (this.inbuf_ < 56) { + this.update(this.pad_, 56 - this.inbuf_); + } else { + this.update(this.pad_, BLOCK_SIZE - (this.inbuf_ - 56)); + } + + // Add # bits. + for (let i = BLOCK_SIZE - 1; i >= 56; i--) { + this.buf_[i] = totalBits & 255; + totalBits /= 256; // Don't use bit-shifting here! + } + + this.compress_(this.buf_); + + let n = 0; + for (let i = 0; i < 5; i++) { + for (let j = 24; j >= 0; j -= 8) { + digest[n] = (this.chain_[i] >> j) & 255; + ++n; + } + } + + return digest; + } +} + +exports = Sha1; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/sha1_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/sha1_test.js new file mode 100644 index 00000000000..1385d776a0d --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/sha1_test.js @@ -0,0 +1,60 @@ +// 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. + +goog.module('mr.Sha1.test'); +goog.setTestOnly(); + +const Sha1 = goog.require('mr.Sha1'); + +/** + * Converts an array of byte values to a hexadecimal string. + * @param {!Array<number>} bytes + * @return {string} + */ +function toHex(bytes) { + return bytes.map(byte => byte.toString(16).padStart(2, '0')).join(''); +} + +describe('mr.Sha1', () => { + // Test vectors from: + // csrc.nist.gov/publications/fips/fips180-2/fips180-2withchangenotice.pdf + + let sha1; + + beforeEach(() => { + sha1 = new Sha1(); + }); + + it('hashes an empty stream correctly', () => { + expect(toHex(sha1.digest())) + .toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709'); + }); + + it('hashes a one-block message correctly', () => { + sha1.update('abc'); + expect(toHex(sha1.digest())) + .toBe('a9993e364706816aba3e25717850c26c9cd0d89d'); + }); + + it('hashes a multi-block message correctly', () => { + sha1.update('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'); + expect(toHex(sha1.digest())) + .toBe('84983e441c3bd26ebaae4aa1f95129e5e54670f1'); + }); + + it('hashes a long message correctly', () => { + const thousandAs = 'a'.repeat(1000); + for (let i = 0; i < 1000; ++i) { + sha1.update(thousandAs); + } + expect(toHex(sha1.digest())) + .toBe('34aa973cd4c4daa4f61eeb2bdbad27316534016f'); + }); + + it('hashes a standard message correctly', () => { + sha1.update('The quick brown fox jumps over the lazy dog'); + expect(toHex(sha1.digest())) + .toBe('2fd4e1c67a2d28fced849ee1bb76e7391b93eb12'); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/string_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/string_utils.js new file mode 100644 index 00000000000..c21aee6dafc --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/string_utils.js @@ -0,0 +1,24 @@ +// 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. + +goog.module('mr.StringUtils'); + +class StringUtils { + /** + * Returns a string with at least 64-bits of randomness. + * + * Doesn't trust Javascript's random function entirely. Uses a combination of + * random and current timestamp, and then encodes the string in base-36 to + * make it shorter. + * + * @return {string} A random string, e.g. sn1s7vb4gcic. + */ + static getRandomString() { + var x = 2147483648; + return Math.floor(Math.random() * x).toString(36) + + Math.abs(Math.floor(Math.random() * x) ^ Date.now()).toString(36); + } +} + +exports = StringUtils; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/tab_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/tab_utils.js new file mode 100644 index 00000000000..730322e3d67 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/tab_utils.js @@ -0,0 +1,28 @@ +// 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. + +/** + * @fileoverview Tab-related utilities methods. + + */ + +goog.provide('mr.TabUtils'); + + +/** + * @param {number} tabId + * @return {!Promise<!Tab>} A promise fulfilled with tab, or rejected if + * tab does not exist. + */ +mr.TabUtils.getTab = function(tabId) { + return new Promise((resolve, reject) => { + chrome.tabs.get(tabId, resolve); + }) + .then(tab => { + if (!tab) { + throw Error('No such tab ' + tabId); + } + return tab; + }); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/throttle.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/throttle.js new file mode 100644 index 00000000000..ecc2967e9d5 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/throttle.js @@ -0,0 +1,93 @@ +// 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. + +goog.module('mr.Throttle'); +goog.module.declareLegacyNamespace(); + + +/** + * Throttle will perform an action that is passed in no more than once + * per interval (specified in milliseconds). If it gets multiple signals + * to perform the action while it is waiting, it will only perform the action + * once at the end of the interval. + * @final + * @template T + */ +class Throttle { + /** + * @param {function(this: T, ...?)} listener Function to callback when the + * action is triggered. + * @param {number} interval Interval over which to throttle. The listener can + * only be called once per interval. + * @param {T=} handler Object in whose scope to call the listener. + */ + constructor(listener, interval, handler = undefined) { + /** @private @const */ + this.listener_ = handler != null ? listener.bind(handler) : listener; + + /** @private @const */ + this.interval_ = interval; + + /** @private @const */ + this.callback_ = this.onTimer_.bind(this); + + /** + * The last arguments passed into `fire`. + * @private {!Array<?>} + */ + this.args_ = []; + + /** + * Indicates that the action is pending and needs to be fired. + * @private {boolean} + */ + this.shouldFire_ = false; + + /** + * Timer for scheduling the next callback + * @private {?number} + */ + this.timerId_ = null; + } + + /** + * Notifies the throttle that the action has happened. It will throttle the + * call so that the callback is not called too often according to the interval + * parameter passed to the constructor, passing the arguments from the last + * call of this function into the throttled function. + * @param {...?} args Arguments to pass on to the throttled function. + */ + fire(...args) { + this.args_ = [...args]; + if (this.timerId_ == null) { + this.doAction_(); + } else { + this.shouldFire_ = true; + } + } + + /** + * Calls the callback + * @private + */ + doAction_() { + this.timerId_ = setTimeout(this.callback_, this.interval_); + this.listener_(...this.args_); + } + + /** + * Handler for the timer to fire the throttle + * @private + */ + onTimer_() { + this.timerId_ = null; + + if (this.shouldFire_) { + this.shouldFire_ = false; + this.doAction_(); + } + } +} + +exports = Throttle; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/throttle_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/throttle_test.js new file mode 100644 index 00000000000..b048a452b5e --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/throttle_test.js @@ -0,0 +1,100 @@ +// 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. + +goog.module('mr.Throttle.test'); +goog.setTestOnly(); + +const Throttle = goog.require('mr.Throttle'); +const UnitTestUtils = goog.require('mr.UnitTestUtils'); + + +describe('mr.Timer', () => { + let clock; + + beforeEach(() => { + clock = UnitTestUtils.useMockClockAndPromises(); + }); + + afterEach(() => { + UnitTestUtils.restoreRealClockAndPromises(); + }); + + it('works', () => { + let callBackCount = 0; + const callBackFunction = () => { + callBackCount++; + }; + + const throttle = new Throttle(callBackFunction, 100); + expect(callBackCount).toBe(0); + throttle.fire(); + expect(callBackCount).toBe(1); + throttle.fire(); + expect(callBackCount).toBe(1); + throttle.fire(); + throttle.fire(); + expect(callBackCount).toBe(1); + clock.tick(101); + expect(callBackCount).toBe(2); + clock.tick(101); + expect(callBackCount).toBe(2); + }); + + it('binds scope correctly', () => { + const interval = 500; + const x = {'y': 0}; + new Throttle(function() { + ++this['y']; + }, interval, x).fire(); + expect(x['y']).toBe(1); + }); + + + it('binds arguments correctly', () => { + const interval = 500; + let calls = 0; + const throttle = new Throttle((a, b, c) => { + ++calls; + expect(a).toBe(3); + expect(b).toBe('string'); + expect(c).toBe(false); + }, interval); + + throttle.fire(3, 'string', false); + expect(calls).toBe(1); + + // fire should always pass the last arguments passed to it into the + // decorated function, even if called multiple times. + throttle.fire(); + clock.tick(interval / 2); + throttle.fire(8, null, true); + throttle.fire(3, 'string', false); + clock.tick(interval); + expect(calls).toBe(2); + }); + + + it('binds arguments and scope correctly', () => { + const interval = 500; + const x = {'calls': 0}; + const throttle = new Throttle(function(a, b, c) { + ++this['calls']; + expect(a).toBe(3); + expect(b).toBe('string'); + expect(c).toBe(false); + }, interval, x); + + throttle.fire(3, 'string', false); + expect(x['calls']).toBe(1); + + // fire should always pass the last arguments passed to it into the + // decorated function, even if called multiple times. + throttle.fire(); + clock.tick(interval / 2); + throttle.fire(8, null, true); + throttle.fire(3, 'string', false); + clock.tick(interval); + expect(x['calls']).toBe(2); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/unit_test_utils.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/unit_test_utils.js new file mode 100644 index 00000000000..e2d8461b2ab --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/unit_test_utils.js @@ -0,0 +1,307 @@ +// 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. + +/** + + * @fileoverview Test utilities for unit tests. + */ +goog.provide('mr.UnitTestUtils'); +goog.setTestOnly('mr.UnitTestUtils'); + +goog.require('mr.Assertions'); +goog.require('mr.MockClock'); +goog.require('mr.MockPromise'); + + +/** + * Creates a mock implementation of the RouteControllerCallbacks interface. + * @return {!Object} + */ +mr.UnitTestUtils.createRouteControllerCallbacksSpyObj = function() { + return jasmine.createSpyObj('RouteControllerCallbacks', [ + 'onRouteControllerInvalidated', 'sendMediaControlRequest', + 'sendVolumeRequest' + ]); +}; + + +/** + * Creates a Mojo InterfaceRequest-like object with Jasmine spy. + * @return {!Object} + */ +mr.UnitTestUtils.createMojoRequestSpyObj = function() { + return jasmine.createSpyObj('Request', ['close']); +}; + + +/** + * Creates a Mojo Binding-like object with Jasmine spy. + * @return {!Object} + */ +mr.UnitTestUtils.createMojoBindingSpyObj = function() { + return jasmine.createSpyObj( + 'Binding', ['bind', 'close', 'setConnectionErrorHandler']); +}; + + +/** + * Creates a Mojo MediaStatusObserver-like object with Jasmine spies. + * @return {!Object} + */ +mr.UnitTestUtils.createMojoMediaStatusObserverSpyObj = function() { + return { + ptr: jasmine.createSpyObj( + 'PtrController', ['reset', 'setConnectionErrorHandler']), + onMediaStatusUpdated: jasmine.createSpy('onMediaStatusUpdated') + }; +}; + + +/** + * Creates a Jasmine spy object with the same methods of the given constructor + * type. + * @param {Function} constructor The object's constructor to be mocked. E.g. + * MyClass.prototype. + * @param {string} mockName The mock's name (will show up in failed test + * results). + * @return {Object} + */ +mr.UnitTestUtils.createMock = function(constructor, mockName) { + const methodNames = new Set(); + while (constructor) { + for (let name of Object.getOwnPropertyNames(constructor)) { + methodNames.add(name); + } + constructor = Object.getPrototypeOf(constructor); + } + return jasmine.createSpyObj(mockName, [...methodNames]); +}; + +/** + * Replaces parts of the window.mojo API with Jasmine spys used by unit tests. + */ +mr.UnitTestUtils.mockMojoApi = function() { + window.mojo = { + Binding: {}, + HangoutsMediaRouteController: {}, + HangoutsMediaStatusExtraData: {}, + MediaStatus: { + PlayState: {PLAYING: 0, PAUSED: 1, BUFFERING: 2}, + }, + MediaController: {}, + RouteControllerType: {kNone: 0, kGeneric: 1, kHangouts: 2}, + TimeDelta: {}, + Origin: {} + }; + // We return a copy of the object used to construct the MediaStatus. + // This works because the fields in the object maps directly to the fields + // in MediaStatus. + spyOn(window.mojo, 'HangoutsMediaStatusExtraData') + .and.callFake(obj => Object.assign({}, obj)); + spyOn(window.mojo, 'MediaStatus').and.callFake(obj => Object.assign({}, obj)); + spyOn(window.mojo, 'TimeDelta').and.callFake(obj => Object.assign({}, obj)); + spyOn(window.mojo, 'Origin').and.callFake(obj => Object.assign({}, obj)); +}; + +/** + * Replaces parts of the chrome API with Jasmine spy objects. After calling this + * function, call restoreChromeApi() during test teardown to restore the + * original API. + */ +mr.UnitTestUtils.mockChromeApi = function() { + mr.UnitTestUtils.originalChromeApi_ = chrome; + chrome = { + cast: { + channel: { + onError: jasmine.createSpy('chrome.cast.channel.onError spy'), + onMessage: jasmine.createSpy('chrome.cast.channel.onMessage spy') + }, + SessionStatus: { + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + STOPPED: 'stopped' + }, + VolumeControlType: + {ATTENUATION: 'attenuation', FIXED: 'fixed', MASTER: 'master'} + }, + dial: { + onDeviceList: jasmine.createSpy('chrome.dial.onDeviceList spy'), + onError: jasmine.createSpy('chrome.dial.onError spy') + }, + identity: { + onSignInChanged: { + addListener: + jasmine.createSpy('chrome.identity.onSignInChanged.addListener spy') + } + }, + runtime: { + onMessage: { + addListener: + jasmine.createSpy('chrome.runtime.onMessage.addListener spy') + }, + onMessageExternal: { + addListener: jasmine.createSpy( + 'chrome.runtime.onMessageExternal.addListener spy') + }, + onStartup: { + addListener: + jasmine.createSpy('chrome.runtime.onStartup.addListener spy') + }, + onSuspend: { + addListener: + jasmine.createSpy('chrome.runtime.onSuspend.addListener spy') + }, + getManifest: () => { + return {version: '1.0'}; + }, + sendMessage: jasmine.createSpy('chrome.runtime.sendMessage spy'), + }, + mdns: {onSerivceList: jasmine.createSpy('chrome.mdns.onServiceList spy')}, + metricsPrivate: { + recordMediumTime: + jasmine.createSpy('chrome.metricsPrivate.recordMediumTime spy'), + recordUserAction: + jasmine.createSpy('chrome.metricsPrivate.recordUserAction spy'), + }, + networkingPrivate: { + onNetworksChanged: + jasmine.createSpy('chrome.networkingPrivate.onNetworksChanged spy') + }, + gcm: { + onMessage: { + addListener: jasmine.createSpy('chrome.gcm.onMessage.addListener spy') + } + }, + }; +}; + +/** + * Restores the original chrome API after it's been mocked by a mockChromeApi() + * call. + */ +mr.UnitTestUtils.restoreChromeApi = function() { + chrome = mr.UnitTestUtils.originalChromeApi_; +}; + +/** + * Stores a reference to the original chrome API while it is mocked. + * @private {?Object} + */ +mr.UnitTestUtils.originalChromeApi_ = null; + + + +/** + * @private {mr.MockClock} + */ +mr.UnitTestUtils.mockClock_ = null; + + +/** + * Installs a mock clock and replaces the native Promise type with goog.Promise. + * + + * + * @return {!mr.MockClock} + */ +mr.UnitTestUtils.useMockClockAndPromises = function() { + mr.Assertions.assert(mr.UnitTestUtils.mockClock_ == null); + mr.MockPromise.install(); + const mockClock = new mr.MockClock(true); + mr.UnitTestUtils.mockClock_ = mockClock; + return mockClock; +}; + + +/** + * Undoes the effect of calling useMockClockAndPromises(). + */ +mr.UnitTestUtils.restoreRealClockAndPromises = function() { + mr.UnitTestUtils.mockClock_.uninstall(); + mr.UnitTestUtils.mockClock_ = null; + mr.MockPromise.uninstall(); +}; + + +/** + * Asserts that mock clock and mock promises are in use. + * @private + */ +mr.UnitTestUtils.assertUsingMockPromises_ = function() { + mr.Assertions.assert(Promise === mr.MockPromise); +}; + + +/** + * Waits for the provided promise to resolve. + * @param {!Promise<T>} promise The promise to resolve. + * @template T + */ +mr.UnitTestUtils.awaitPromiseResolved = function(promise) { + mr.UnitTestUtils.assertUsingMockPromises_(); + let resolved = false; + promise.then(result => { + resolved = true; + }); + mr.MockPromise.callPendingHandlers(); + expect(resolved).toBe(true); +}; + + +/** + * Expects the provided promise to resolve to a value that makes the given + * predicate true. + * @param {!Promise<T>} promise The promise to resolve. + * @param {function(T):boolean} isCorrect A function called with the resolved + * value of the promise; expected to return true. + * @template T + */ +mr.UnitTestUtils.expectPromiseResult = function(promise, isCorrect) { + mr.UnitTestUtils.assertUsingMockPromises_(); + let resolved = false; + let actual; + promise.then(result => { + resolved = true; + actual = result; + }); + mr.MockPromise.callPendingHandlers(); + expect(resolved).toBe(true); + expect(isCorrect(actual)).toBe(true); +}; + + +/** + * Expects the provided promise to resolve to the given result. + * @param {!Promise} promise + * @param {*} expectedResult + */ +mr.UnitTestUtils.expectPromiseResultToEqual = function( + promise, expectedResult) { + mr.UnitTestUtils.assertUsingMockPromises_(); + let resolved = false; + let actual; + promise.then(result => { + resolved = true; + actual = result; + }); + mr.MockPromise.callPendingHandlers(); + expect(resolved).toBe(true); + expect(actual).toEqual(expectedResult); +}; + + +/** + * Expects the provided promise to resolve to the given result. + * @param {!Promise} promise + * @param {Error} expectedError + */ +mr.UnitTestUtils.expectPromiseRejection = function(promise, expectedError) { + mr.UnitTestUtils.assertUsingMockPromises_(); + let actual; + promise.catch(error => { + actual = error; + }); + mr.MockPromise.callPendingHandlers(); + expect(actual).toEqual(expectedError); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/xhr_manager.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/xhr_manager.js new file mode 100644 index 00000000000..41017f63493 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/xhr_manager.js @@ -0,0 +1,191 @@ +// 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. + +goog.module('mr.XhrManager'); +goog.module.declareLegacyNamespace(); + +const Assertions = goog.require('mr.Assertions'); +const PromiseResolver = goog.require('mr.PromiseResolver'); + +/** + * Wraps XmlHttpRequest API with additional functionalities such as queueing, + * timeout, and retries. + */ +class XhrManager { + /** + * @param {number} maxRequests The maximum number of concurrent XHR + * requests. + * @param {number} defaultTimeoutMillis The default timeout for each XHR + * request. + * @param {number} defaultNumAttempts The default number of attempts for each + * operation. + */ + constructor(maxRequests, defaultTimeoutMillis, defaultNumAttempts) { + /** @private @const {number} */ + this.maxRequests_ = maxRequests; + + /** @private @const {number} */ + this.defaultTimeoutMillis_ = defaultTimeoutMillis; + + /** @private @const {number} */ + this.defaultNumAttempts_ = defaultNumAttempts; + + /** + * Number of pending requests. Does not exceed this.maxRequests_. + * @private {number} + */ + this.numPendingRequests_ = 0; + + /** + * Holds requests that have not yet been executed. + * @private {!Array<!QueueEntry_>} + */ + this.queuedRequests_ = []; + } + + /** + * Adds a request with the given parameters. + * @param {string} url + * @param {string} method + * @param {string=} body + * @param {{timeoutMillis: (number|undefined), + * numAttempts: (number|undefined), + * headers: (!Array<!Array<string>>|undefined), + * responseType: (string|undefined)}=} overrides "headers" is an Array + * of pairs of strings. "responseType" is a valid + * XMLHttpRequest.responseType enum value. + * @return {!Promise<!XMLHttpRequest>} Resolves with the response. Rejects if + * the request timed out on all attempts. + */ + send(url, method, body = undefined, { + timeoutMillis = this.defaultTimeoutMillis_, + numAttempts = this.defaultNumAttempts_, + headers = null, + responseType = '', + } = {}) { + const entry = { + resolver: new PromiseResolver(), + url: url, + method: method, + headers: headers, + responseType: responseType, + body: body, + timeoutMillis: timeoutMillis, + numAttemptsLeft: numAttempts + }; + + if (this.numPendingRequests_ < this.maxRequests_) { + this.startRequest_(entry); + } else { + this.queuedRequests_.push(entry); + } + + return entry.resolver.promise; + } + + /** + * Starts a request from the request queue if there is room for an additional + * pending request. + * @private + */ + startNextRequestFromQueue_() { + if (this.queuedRequests_.length > 0 && + this.numPendingRequests_ < this.maxRequests_) { + const request = this.queuedRequests_.shift(); + this.startRequest_(request); + } + } + + /** + * Attempts the given request once. If successful, resolves the + * PromiseResolver with the result. Otherwise, requeues the request for retry, + * or rejects the PromiseResolver if it ran out of attempts. Also processes a + * queued request, if any, at the end. + * @param {!QueueEntry_} request + * @private + */ + startRequest_(request) { + this.numPendingRequests_++; + Assertions.assert( + request.numAttemptsLeft > 0, 'request.numAttemptsLeft > 0'); + request.numAttemptsLeft--; + + const cleanUpAndStartNextRequest = () => { + this.numPendingRequests_--; + this.startNextRequestFromQueue_(); + }; + + this.sendOneAttempt_(request).then( + response => { + request.resolver.resolve(response); + cleanUpAndStartNextRequest(); + }, + e => { + if (request.numAttemptsLeft == 0) { + request.resolver.reject(e); + } else { + // Try it again later by re-adding the request to back of queue. + + this.queuedRequests_.push(request); + } + cleanUpAndStartNextRequest(); + }); + } + + /** + * Executes a XMLHttpRequest and returns a Promise that resolves with the + * response, or rejects if the request times out. + * @param {!QueueEntry_} request + * @return {!Promise<!XMLHttpRequest>} Resolves with the response. Rejects if + * the request timed out. + * @private + */ + sendOneAttempt_(request) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = () => { + if (xhr.readyState == XMLHttpRequest.DONE) { + resolve(xhr); + } + }; + + xhr.timeout = request.timeoutMillis; + xhr.ontimeout = () => { + reject(new Error('Timed out')); + }; + + xhr.open(request.method, request.url, true); + if (request.headers == null) { + xhr.setRequestHeader( + 'Content-Type', 'application/x-www-form-urlencoded;charset=utf-8'); + } else { + request.headers.forEach( + header => xhr.setRequestHeader(header[0], header[1])); + } + xhr.responseType = request.responseType; + xhr.send(request.body); + }); + } +} + +/** @private @record */ +const QueueEntry_ = class {}; +/** @type {!PromiseResolver<!XMLHttpRequest>} */ +QueueEntry_.prototype.resolver; +/** @type {string} */ +QueueEntry_.prototype.url; +/** @type {string} */ +QueueEntry_.prototype.method; +/** @type {?Array<!Array<string>>} */ +QueueEntry_.prototype.headers; +/** @type {string} */ +QueueEntry_.prototype.responseType; +/** @type {string|undefined} */ +QueueEntry_.prototype.body; +/** @type {number} */ +QueueEntry_.prototype.timeoutMillis; +/** @type {number} */ +QueueEntry_.prototype.numAttemptsLeft; + +exports = XhrManager; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/utils/xhr_manager_test.js b/chromium/chrome/browser/resources/media_router/extension/src/utils/xhr_manager_test.js new file mode 100644 index 00000000000..44857e2a23d --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/utils/xhr_manager_test.js @@ -0,0 +1,185 @@ +// 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. + +goog.module('mr.XhrManagerTest'); +goog.setTestOnly('mr.XhrManagerTest'); + +const XhrManager = goog.require('mr.XhrManager'); + +describe('XhrManager Tests', function() { + const MAX_REQUESTS = 5; + const DEFAULT_TIMEOUT = 10; + const DEFAULT_ATTEMPTS = 3; + const SEND_URL = 'https://www.google.com'; + const BODY = 'body'; + + let mockXhr; + let xhrManager; + + beforeEach(() => { + mockXhr = jasmine.createSpyObj('Xhr', ['open', 'send', 'setRequestHeader']); + xhrManager = + new XhrManager(MAX_REQUESTS, DEFAULT_TIMEOUT, DEFAULT_ATTEMPTS); + }); + + it('Resolves on first try with defaults', done => { + spyOn(window, 'XMLHttpRequest').and.returnValue(mockXhr); + mockXhr.open.and.callFake((method, url, async) => { + expect(mockXhr.onreadystatechange).toBeDefined(); + expect(mockXhr.timeout).toEqual(DEFAULT_TIMEOUT); + expect(mockXhr.ontimeout).toBeDefined(); + expect(method).toEqual('POST'); + expect(url).toEqual(SEND_URL); + expect(async).toBe(true); + }); + const headersThatWereSet = []; + mockXhr.setRequestHeader.and.callFake((key, value) => { + headersThatWereSet.push([key, value]); + }); + mockXhr.send.and.callFake(body => { + expect(mockXhr.open).toHaveBeenCalled(); + expect(mockXhr.setRequestHeader).toHaveBeenCalled(); + expect(headersThatWereSet).toEqual([ + ['Content-Type', 'application/x-www-form-urlencoded;charset=utf-8'] + ]); + expect(mockXhr.responseType).toBe(''); + expect(body).toEqual(BODY); + + setTimeout(() => { + mockXhr.readyState = XMLHttpRequest.DONE; + mockXhr.onreadystatechange(); + }, 0); + }); + + xhrManager.send(SEND_URL, 'POST', BODY).then(response => { + expect(mockXhr.send).toHaveBeenCalled(); + done(); + }); + }); + + it('Resolves on first try with overrides', done => { + const overrides = { + timeoutMillis: 1234, + headers: [['Hello', 'World'], ['Eat', 'Cheese']], + responseType: 'arraybuffer' + }; + + spyOn(window, 'XMLHttpRequest').and.returnValue(mockXhr); + mockXhr.open.and.callFake((method, url, async) => { + expect(mockXhr.onreadystatechange).toBeDefined(); + expect(mockXhr.timeout).toEqual(overrides.timeoutMillis); + expect(mockXhr.ontimeout).toBeDefined(); + expect(method).toEqual('GET'); + expect(url).toEqual(SEND_URL); + expect(async).toBe(true); + }); + const headersThatWereSet = []; + mockXhr.setRequestHeader.and.callFake((key, value) => { + headersThatWereSet.push([key, value]); + }); + mockXhr.send.and.callFake(body => { + expect(mockXhr.open).toHaveBeenCalled(); + expect(mockXhr.setRequestHeader).toHaveBeenCalled(); + expect(headersThatWereSet).toEqual(overrides.headers); + expect(mockXhr.responseType).toBe(overrides.responseType); + expect(body).toEqual(BODY); + + setTimeout(() => { + mockXhr.readyState = XMLHttpRequest.DONE; + mockXhr.onreadystatechange(); + }, 0); + }); + + xhrManager.send(SEND_URL, 'GET', BODY, overrides).then(response => { + expect(mockXhr.send).toHaveBeenCalled(); + done(); + }); + }); + + it('Resolves on retry', done => { + spyOn(window, 'XMLHttpRequest').and.returnValue(mockXhr); + let numAttempts = 0; + mockXhr.send.and.callFake(() => { + expect(mockXhr.timeout).toBe(DEFAULT_TIMEOUT); + ++numAttempts; + if (numAttempts <= 1) { + setTimeout(() => mockXhr.ontimeout(), DEFAULT_TIMEOUT); + } else { + setTimeout(() => { + mockXhr.readyState = XMLHttpRequest.DONE; + mockXhr.onreadystatechange(); + }, 0); + } + }); + + xhrManager.send(SEND_URL, 'GET').then(() => { + expect(numAttempts).toBe(2); + done(); + }); + }); + + it('Queues requests', done => { + let xhrs = []; + spyOn(window, 'XMLHttpRequest').and.callFake(() => { + const mockXhr = + jasmine.createSpyObj('Xhr', ['open', 'send', 'setRequestHeader']); + mockXhr.send.and.callFake(() => { + expect(xhrs.length).toBeLessThan(MAX_REQUESTS); + xhrs.push(mockXhr); + }); + return mockXhr; + }); + + let promises1 = []; + let promises2 = []; + for (let i = 0; i < MAX_REQUESTS; i++) { + promises1.push(xhrManager.send(SEND_URL, 'POST', BODY)); + } + for (let i = 0; i < MAX_REQUESTS; i++) { + promises2.push(xhrManager.send(SEND_URL, 'POST', BODY)); + } + + // Resolve the first 5 requests. + expect(xhrs.length).toBe(MAX_REQUESTS); + while (xhrs.length > 0) { + let xhr = xhrs.shift(); + setTimeout(() => { + xhr.readyState = XMLHttpRequest.DONE; + xhr.onreadystatechange(); + }, 0); + } + + Promise.all(promises1).then(() => { + // Resolve the next 5 requests. + expect(xhrs.length).toBe(MAX_REQUESTS); + while (xhrs.length > 0) { + let xhr = xhrs.shift(); + setTimeout(() => { + xhr.readyState = XMLHttpRequest.DONE; + xhr.onreadystatechange(); + }, 0); + } + Promise.all(promises2).then(done); + }); + }); + + it('Rejects if all retries failed', done => { + spyOn(window, 'XMLHttpRequest').and.returnValue(mockXhr); + let numAttempts = 0; + mockXhr.send.and.callFake((path, init) => { + ++numAttempts; + setTimeout(() => mockXhr.ontimeout(), DEFAULT_TIMEOUT); + }); + + xhrManager.send(SEND_URL, 'GET') + .then( + _ => { + fail('send() unexpectedly succeeded.'); + }, + () => { + expect(numAttempts).toBe(DEFAULT_ATTEMPTS); + done(); + }); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/extension/src/webrtc/messages.js b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/messages.js new file mode 100644 index 00000000000..6f8e6d43b3c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/messages.js @@ -0,0 +1,200 @@ +// 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. + +goog.provide('mr.webrtc.AuthReadyMessageData'); +goog.provide('mr.webrtc.ChannelType'); +goog.provide('mr.webrtc.Message'); +goog.provide('mr.webrtc.MessageType'); +goog.provide('mr.webrtc.OfferMessageData'); + +goog.require('mr.mirror.Settings'); + +goog.scope(function() { + + +/** + * The channel types supported by the cloud MRP. + * @enum {string} + */ +mr.webrtc.ChannelType = { + WEAVE: 'weave', + SLARTI: 'slarti', + MESI: 'mesi' +}; + + +/** + * The types of messages used by the cloud MRP. + * @enum {string} + */ +mr.webrtc.MessageType = { + // TURN messages. + GET_TURN_CREDENTIALS: 'GET_TURN_CREDENTIALS', // Request for creds. + TURN_CREDENTIALS: 'TURN_CREDENTIALS', // Creds received. + + // Signaling messages. + OFFER: 'OFFER', // Peer connection offer. + ANSWER: 'ANSWER', // Peer connection answer. + KNOCK_ANSWER: 'KNOCK_ANSWER', // Knocking peer connection answer. + STOP: 'STOP', // Stop the session. + + // Event messages. + SESSION_START_SUCCESS: 'SESSION_START_SUCCESS', // Start success event. + SESSION_FAILURE: 'SESSION_FAILURE', // Start failure event. + SESSION_END: 'SESSION_END', // Session ended event. + + WEB_RTC_STATS: '__webrtc_stats__', // WebRTC stats message. + + // Hangout session control messages. + REFRESH_AUTH: 'REFRESH_AUTH', // Request to refresh the auth token. + // Message data for AUTH_READY messages will be an instance of + // mr.webwrtc.AuthReadyMessageData. + AUTH_READY: 'AUTH_READY', // Response that auth token was updated. + + // Route details control messages. + MUTE: 'MUTE', // Request to mute audio. + LOCAL_PRESENT: 'LOCAL_PRESENT', // Request to disable conf mode. + ROUTE_STATUS_REQUEST: 'STATUS_REQUEST', // Request for route status update. + ROUTE_STATUS_RESPONSE: + 'STATUS_RESPONSE', // Response to route details status. + + // Hangout issues. + HANGOUT_INVALID: 'HANGOUT_INVALID', // Hangout name could not be resolved. + HANGOUT_INACTIVE: 'HANGOUT_INACTIVE', // Not enough participants in Hangout. + + // Application messages sent through Presentation API. + PRESENTATION_CONNECTION_MESSAGE: 'PRESENTATION_CONNECTION_MESSAGE', +}; + + +/** + * Cloud message object used for internal communication. + */ +mr.webrtc.Message = class { + /** + * @param {mr.webrtc.MessageType} type + * @param {!Object|undefined=} opt_data + */ + constructor(type, opt_data) { + /** + * @type {mr.webrtc.MessageType} + * @export + */ + this.type = type; + /** + * @type {!Object|undefined} + * @export + */ + this.data = opt_data; + } + + /** + * Returns the message for the provided JSON string. + * @param {string} messageStr + * @return {!mr.webrtc.Message} + */ + static fromString(messageStr) { + const messageJson = JSON.parse(messageStr); + if (!messageJson['type']) { + throw Error('Invalid message'); + } + return new Message( + /** @type {mr.webrtc.MessageType} */ (messageJson['type']), + /** @type {!Object|undefined} */ (messageJson['data'])); + } + + /** + * Constructs an AUTH_READY message. + * @param {!mr.webrtc.AuthReadyMessageData} data + * @return {!mr.webrtc.Message} + */ + static authReady(data) { + return new Message(mr.webrtc.MessageType.AUTH_READY, data); + } + + /** + * Workaround for broken handling of ES6 classes in Jasmine. + * @return {string} + */ + jasmineToString() { + return '[mr.webrtc.Message instance]'; + } +}; + +const Message = mr.webrtc.Message; + + +/** + * The data for an offer message. Setting the presentation url in the WebRTC + * offer message indicates to the receiver that the session is a presentation + * session. + */ +mr.webrtc.OfferMessageData = class { + /** + * @param {RTCSessionDescription} description + * @param {mr.mirror.Settings=} opt_settings + * @param {MediaConstraints=} opt_mediaConstraints + * @param {string=} opt_presentationUrl + * @param {string=} opt_presentationId + */ + constructor( + description, opt_settings, opt_mediaConstraints, opt_presentationUrl, + opt_presentationId) { + /** + * @type {RTCSessionDescription} + * @export + */ + this.description = description; + + /** + * @type {?mr.mirror.Settings} + * @export + */ + this.settings = opt_settings || null; + + /** + * @type {?MediaConstraints} + * @export + */ + this.mediaConstraints = opt_mediaConstraints || null; + + /** + * @type {?string} + * @export + */ + this.presentationUrl = opt_presentationUrl || null; + + /** + * @type {?string} + * @export + */ + this.presentationId = opt_presentationId || null; + } +}; + + +/** + * Data associated with an AUTH_READY message. + * + * Fields: + * + * - isMeeting: True for Thor meetings, false for Hangouts. + * + * - hangoutId: The public ID of the Hangout/meeting. + * + * - resolvedId: The resolved (internal) ID of the Hangout/meeting. May be ''. + * + * - conferenceMode: If defined, used to override the setting of the + * isConferenceMode_ field of HangoutSession. + * + * @typedef {{ + * isMeeting: boolean, + * hangoutId: string, + * resolvedId: string, + * conferenceMode: (boolean|undefined) + * }} + */ +mr.webrtc.AuthReadyMessageData; + +}); // goog.scope diff --git a/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection.js b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection.js new file mode 100644 index 00000000000..4517bc2c85c --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection.js @@ -0,0 +1,570 @@ +// 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. + +goog.provide('mr.webrtc.PeerConnection'); +goog.provide('mr.webrtc.TurnCredential'); + +goog.require('mr.Assertions'); +goog.require('mr.Logger'); +goog.require('mr.PromiseResolver'); +goog.require('mr.webrtc.PeerConnectionAnalytics'); + +goog.scope(function() { + + +/** + * Type of credentials returned by the TURN credential service. Can be used + * as-is in the 'iceConnection' property of the configuration passed to the + * webkitRTCPeerConnection() constructor. + * + * @typedef {{ + * username: string, + * credential: string, + * url: string + * }} + */ +mr.webrtc.TurnCredential; + + +/** + * Creates a new PeerConnection. + */ +mr.webrtc.PeerConnection = class { + /** + * @param {string} routeId The route ID for the route to open this + * PeerConnection. Used as the data channel ID. + * @param {!Array<!mr.webrtc.TurnCredential>} turnCreds + * TURN server credentials. + */ + constructor(routeId, turnCreds) { + mr.Assertions.assert( + webkitRTCPeerConnection !== undefined, + 'webkitRTCPeerConnection is not available. Do you need to set flags?'); + + /** + * Logger instance. + * @private {mr.Logger} + */ + this.logger_ = mr.Logger.getInstance('cv2.PeerConnection'); + + /** + * The media constraints to assign to the PeerConnection. + * @type {!MediaConstraints | undefined} + * @private + */ + this.mediaConstraints_ = PeerConnection.MEDIA_CONSTRAINTS; + + /** + * WebRTC PeerConnection wrapped by this class. + * @private {webkitRTCPeerConnection} + */ + this.peerConnection_ = this.createPeerConnection_(turnCreds); + + /** + * WebRTC data channel (if it should be enabled). + * @type {!RTCDataChannel} + * @private + */ + this.dataChannel_ = this.createDataChannel_(routeId); + + + + /** + * Whether we will try an ICE restart when we are in a disconnected state. + * @private {boolean} + */ + this.enableIceRestart_ = true; + + + + /** + * Resolves when the Web RTC session description is received. + * @private {!mr.PromiseResolver<RTCSessionDescription>} + */ + this.sessionDescriptionResolver_ = new mr.PromiseResolver(); + + /** + * True if sessionDescriptionResolver_ has been resolved. + * @private {boolean} + */ + this.sessionDescriptionResolved_ = false; + + /** + * The number of ICE candidates that have been received so far. + * @private {number} + */ + this.numIceCandidatesReceived_ = 0; + + /** + * ID of the timer used to abort ICE candidate gathering. + * @private {?number} + */ + this.iceCandidateGatheringTimerId_ = null; + + /** + * The time when the offer is created. + * @private {number} + */ + this.offerCreationTime_ = 0; + + /** + * The time when the most recent ICE candidate was received. + * @private {number} + */ + this.lastIceCandidateTime_ = 0; + + /** + * True if the PeerConnection has been started. + * @private {boolean} + */ + this.started_ = false; + + /** + * Callback invoked when a PeerConnection has connection issues. + * @private {function()} + */ + this.onConnectionStale_ = () => {}; + + /** + * Callback invoked when the offer description is ready. + * @private {function(RTCSessionDescription)} + */ + this.onOfferDescription_ = () => {}; + + /** + * Callback invoked when the connection is successfully made. + * @private {function(mr.webrtc.PeerConnection.Event)} + */ + this.onConnectionSuccess_ = () => {}; + + /** + * Callback invoked when the connection fails. + * @private {function(mr.webrtc.PeerConnection.Event)} + */ + this.onConnectionFailure_ = () => {}; + + /** + * Callback invoked when the connection is closed. + * @type {function(mr.webrtc.PeerConnection.Event)} + * @private + */ + this.onConnectionClosed_ = () => {}; + } + + /** + * @param {function()} staleFn The callback. + */ + setOnConnectionStale(staleFn) { + this.onConnectionStale_ = staleFn; + } + + /** + * @param {function(mr.webrtc.PeerConnection.Event)} successFn + */ + setOnConnectionSuccess(successFn) { + this.onConnectionSuccess_ = successFn; + } + + /** + * @param {function(mr.webrtc.PeerConnection.Event)} failureFn + */ + setOnConnectionFailure(failureFn) { + this.onConnectionFailure_ = failureFn; + } + + /** + * @param {function(mr.webrtc.PeerConnection.Event)} closedFn + */ + setOnConnectionClosed(closedFn) { + this.onConnectionClosed_ = closedFn; + } + + /** + * @param {function(RTCSessionDescription)} onOfferFn + */ + setOnOfferDescriptionReady(onOfferFn) { + this.onOfferDescription_ = onOfferFn; + } + + /** + * @param {function(string)} onMessageFn + */ + setOnDataChannelMessage(onMessageFn) { + this.dataChannel_.onmessage = function(event) { + onMessageFn(event.data); + }; + } + + /** + * @param {boolean} shouldEnable Whether we should enable ICE restart. + */ + enableIceRestart(shouldEnable) { + this.enableIceRestart_ = shouldEnable; + } + + /** + * @return {boolean} true if the PeerConnection has been started. + */ + isStarted() { + return this.started_; + } + + /** + * Returns the configuration data for the WebRTC PeerConnection. + * @return {!RTCConfiguration} The configuration. + * @param {!Array<!mr.webrtc.TurnCredential>} turnCreds + * TURN server credentials. + * @private + * @suppress {invalidCasts} invalid cast - must be a subtype or supertype + * from: {} + * to : (!RTCConfiguration) + */ + getPeerConnectionConfig_(turnCreds) { + const server = {}; + server['url'] = 'stun:stun.l.google.com:19302'; + const config = {}; + config['iceServers'] = [server].concat(turnCreds); + return /** @type {!RTCConfiguration} */ (config); + } + + /** + * Creates the WebRTC PeerConnection object. + * @return {webkitRTCPeerConnection} The new PC. + * @param {!Array<!mr.webrtc.TurnCredential>} turnCreds + * TURN server credentials. + * @private + */ + createPeerConnection_(turnCreds) { + const config = this.getPeerConnectionConfig_(turnCreds); + const peerConnection = new webkitRTCPeerConnection(config); + peerConnection.onicecandidate = this.onIceCandidate_.bind(this); + peerConnection.onicegatheringstatechange = + this.onIceGatheringStateChange_.bind(this); + peerConnection.oniceconnectionstatechange = + this.onIceConnectionStateChange_.bind(this); + this.logger_.info( + () => 'Created webkitRTCPeerConnnection with config: ' + + JSON.stringify(config)); + return peerConnection; + } + + /** + * Creates a data channel. Must be called in the initiator before the SDP is + * created. + * @param {string} channelId + * @return {!RTCDataChannel} + * @private + */ + createDataChannel_(channelId) { + const dataChannel = + this.peerConnection_.createDataChannel(channelId, {'reliable': false}); + return dataChannel; + } + + /** + * Sends the provided message via the data channel. + * @param {!Object|string} message The message to send. + */ + sendDataChannelMessage(message) { + if (typeof message == 'string') { + this.dataChannel_.send(message); + } else { + this.dataChannel_.send(JSON.stringify(message)); + } + } + + /** + * Starts the PeerConnection with any added streams. + */ + start() { + if (!this.started_) { + this.started_ = true; + // Caller initiates offer to peer. + this.createOffer_(); + } + } + + /** + * Stops this PeerConnection. + */ + stop() { + this.logger_.info('Stopping peer connection...'); + if (this.started_) { + this.started_ = false; + if (this.peerConnection_.signalingState != 'closed') { + this.peerConnection_.close(); + } + } + this.peerConnection_ = null; + } + + /** + * Adds a stream to the PeerConnection. + * @param {!MediaStream} stream The media stream to add. + */ + addStream(stream) { + this.peerConnection_.addStream(stream); + } + + /** + * Removes a stream from the PeerConnection. + * @param {!MediaStream} stream The media stream to remove. + */ + removeStream(stream) { + if (this.started_) { + this.peerConnection_.removeStream(stream); + } + } + + /** + * Initiates a call to the peer. + * @private + */ + createOffer_() { + this.logger_.info('Sending offer to peer.'); + this.offerCreationTime_ = Date.now(); + + this.peerConnection_.createOffer( + this.setLocalDescription_.bind(this), error => { + this.logger_.warning('Error creating offer.', error); + }, this.mediaConstraints_); + this.getSessionDescription().then(sessionDescription => { + this.onOfferDescription_(sessionDescription); + }); + } + + /** + * Sets the local description for the session. + * @param {!RTCSessionDescription} sessionDescription The offer. + * @private + */ + setLocalDescription_(sessionDescription) { + + this.logger_.info( + () => + 'Setting local description: ' + JSON.stringify(sessionDescription)); + this.peerConnection_.setLocalDescription( + sessionDescription, + () => { + this.logger_.info('Local description set successfully'); + }, + error => { + this.logger_.warning('Error setting local description.', error); + }); + // Cloud connections only send messages when ICE gathering is complete. + // This is done in createOffer_ for cloud connections instead. + } + + /** + * There's currently a WebRTC "bug" which means we can't JSON.stringify an + * RTCSessionDescription. See http://b/19817649. So for now, we'll just put it + * in our own object. + + * @param {RTCSessionDescription} description + * @return {RTCSessionDescription} + * @private + */ + formDescriptionMessage_(description) { + return /** @type {RTCSessionDescription} */ ( + {'type': description.type, 'sdp': description.sdp}); + } + + /** + * Returns the session offer description (if this is the sender) or answer + * description (if this is the receiver). + * Note that this description contains all of the ICE candidates as well. + * @return {!Promise<RTCSessionDescription>} + */ + getSessionDescription() { + return this.sessionDescriptionResolver_.promise; + } + + /** + * Sets the remote description on the peer connection. + * @param {!RTCSessionDescription} sessionDescription + */ + setRemoteDescription(sessionDescription) { + this.logger_.fine(() => '<===: ' + JSON.stringify(sessionDescription)); + const description = new RTCSessionDescription(sessionDescription); + this.logger_.info( + () => 'Setting remote description: ' + JSON.stringify(description)); + // We received an answer! Just set the description. + this.peerConnection_.setRemoteDescription( + description, + () => { + this.logger_.info('Remote description set successfully.'); + }, + error => { + this.logger_.warning('Error setting remote description.', error); + }); + } + + /** + * Resolves the session description once all ice candidates have been + * received. + * @param {RTCPeerConnectionIceEvent} event The ICE candidate event. + * @private + */ + onIceCandidate_(event) { + if (event.candidate) { + this.numIceCandidatesReceived_++; + this.lastIceCandidateTime_ = Date.now(); + if (this.numIceCandidatesReceived_ == 1) { + // This is the first ICE candidate. Set a timer to abort gathering + // candidates. This is needed because sometimes the end of ICE + // candidate + // gathering is not detected right away even though all candidates have + // been gathered. + mr.Assertions.assert(this.iceCandidateGatheringTimerId_ == null); + this.iceCandidateGatheringTimerId_ = setTimeout(() => { + this.logger_.info('ICE candidate gathering timed out.'); + this.iceCandidateGatheringTimerId_ = null; + this.resolveSessionDescription_(); + }, PeerConnection.ICE_CANDIDATE_GATHERING_TIMEOUT_MS_); + } else if (this.sessionDescriptionResolved_) { + // This branch runs when additional ICE candidates are reported after + // the timeout above fires. + this.logger_.warning( + 'Received ICE candidate after resolving session description.'); + } + } else { + this.logger_.info('End of ICE candidates.'); + mr.webrtc.PeerConnectionAnalytics + .recordIceCandidateGatheringReportedDuration( + Date.now() - this.offerCreationTime_); + + // This is a no-op if the timout above has already fired. + this.resolveSessionDescription_(); + + // Record the true duration of candidate gathering based on the time the + // last candidate was reported. Don't record anything if no candidates + // were + // found, because the computed duration will be invalid. + if (this.numIceCandidatesReceived_ > 0) { + mr.webrtc.PeerConnectionAnalytics + .recordIceCandidateGatheringRealDuration( + this.lastIceCandidateTime_ - this.offerCreationTime_); + } + } + } + + /** + * Called when ICE gathering state changes. Tracks when the candidates are + * complete. + * @private + */ + onIceGatheringStateChange_() { + // This method never appears to be called. + const state = this.peerConnection_.iceGatheringState; + if (state == 'completed') { + this.resolveSessionDescription_(); + } + } + + /** + * Resolves the session description promise. Should be called after all ICE + * candidates have been received. + * @private + */ + resolveSessionDescription_() { + clearTimeout(this.iceCandidateGatheringTimerId_); + this.iceCandidateGatheringTimerId_ = null; + if (!this.sessionDescriptionResolved_) { + this.logger_.info( + 'Resolving sesion description after gathering ' + + this.numIceCandidatesReceived_ + ' ICE candidates.'); + this.sessionDescriptionResolver_.resolve( + this.formDescriptionMessage_(this.peerConnection_.localDescription)); + this.sessionDescriptionResolved_ = true; + } + } + + /** + * Handles ICE connection state changes. Tries to restart PeerConnection when + * we + * are in a disconnected state by creating a new offer with the IceRestart + * constraint. + * @param {Event} event + * @private + */ + onIceConnectionStateChange_(event) { + if (!this.peerConnection_) return; + + const state = this.peerConnection_.iceConnectionState; + this.logger_.info('New ICE connection state: ' + state + '.'); + if (state == 'connected') { + this.onConnectionSuccess_(PeerConnection.Event.ICE_CONNECTED); + } else if (state == 'completed') { + this.onConnectionSuccess_(PeerConnection.Event.ICE_COMPLETED); + } else if (state == 'failed') { + this.logger_.warning( + () => 'Ice connection failed: ' + JSON.stringify(event)); + this.onConnectionFailure_(PeerConnection.Event.ICE_FAILED); + } else if (state == 'closed') { + this.onConnectionClosed_(PeerConnection.Event.ICE_CLOSED); + } else if (state == 'disconnected') { + this.logger_.warning('Ice connection state is bad.'); + if (this.enableIceRestart_ && this.isStarted()) { + this.logger_.info('Restarting ICE.'); + this.peerConnection_.createOffer( + this.setLocalDescription_.bind(this), error => { + this.logger_.warning('Error creating new offer.', error); + }, PeerConnection.ICE_RESTART_MEDIA_CONSTRAINTS); + } else { + this.onConnectionStale_(); + } + } + } +}; + +const PeerConnection = mr.webrtc.PeerConnection; + + +/** + * Default media constraints for tab capture. + * @const {!MediaConstraints} + */ +PeerConnection.MEDIA_CONSTRAINTS = { + 'mandatory': {'OfferToReceiveAudio': true, 'OfferToReceiveVideo': true} +}; + + +/** + * Default media constraints during ICE restarts. + * @const {!MediaConstraints} + */ +PeerConnection.ICE_RESTART_MEDIA_CONSTRAINTS = { + 'mandatory': { + 'IceRestart': true, + 'OfferToReceiveAudio': true, + 'OfferToReceiveVideo': true + } +}; + + +/** + * PeerConnection event types. + * @enum {string} + */ +PeerConnection.Event = { + // events that have a corresponding on on<Event> callback. + ADD_STREAM: 'addstream', + REMOVE_STREAM: 'removestream', + ICE_CANDIDATE: 'icecandidate', + // events that do not have a corresponding on on<Event> callback. + ICE_CONNECTED: 'iceconnected', + ICE_COMPLETED: 'icecompleted', + ICE_FAILED: 'icefailed', + ICE_CLOSED: 'iceclosed' +}; + + +/** + * The maximum time to wait, in ms, for all ICE candidates to be gathered. + * Timing starts when the first ICE candidate is seen. + * @private @const + */ +PeerConnection.ICE_CANDIDATE_GATHERING_TIMEOUT_MS_ = 5 * 1000; + +}); // goog.scope diff --git a/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection_analytics.js b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection_analytics.js new file mode 100644 index 00000000000..69b5e3fabd8 --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection_analytics.js @@ -0,0 +1,58 @@ +// 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. + +/** + * @fileoverview Defines UMA analytics specific to peer connection. + */ + +goog.provide('mr.webrtc.PeerConnectionAnalytics'); + +goog.require('mr.Timing'); + + +/** @const {*} */ +mr.webrtc.PeerConnectionAnalytics = {}; + + +/** + * Histogram name for time taken to gather ICE candidates, from the start of + * candidate gethering to the time the last candidate is reported. + * @private @const {string} + */ +mr.webrtc.PeerConnectionAnalytics.ICE_CANDIDATE_GATHERING_REAL_DURATION_ = + 'MediaRouter.WebRtc.IceCandidateGathering.Duration.Real'; + + +/** + * Histogram name for time taken to gather ICE candidates, from the start of + * candidate gethering to the time the the end of collection is reported. + * @private @const {string} + */ +mr.webrtc.PeerConnectionAnalytics.ICE_CANDIDATE_GATHERING_REPORTED_DURATION_ = + 'MediaRouter.WebRtc.IceCandidateGathering.Duration.Reported'; + + +/** + * Records the real duration of ICE candidate gathering. + * @param {number} value + */ +mr.webrtc.PeerConnectionAnalytics.recordIceCandidateGatheringRealDuration = + function(value) { + mr.Timing.recordDuration( + mr.webrtc.PeerConnectionAnalytics.ICE_CANDIDATE_GATHERING_REAL_DURATION_, + value); +}; + + +/** + * Records the reported duration of ICE candidate gathering. + * @param {number} value + */ +mr.webrtc.PeerConnectionAnalytics.recordIceCandidateGatheringReportedDuration = + function(value) { + mr.Timing.recordDuration( + mr.webrtc.PeerConnectionAnalytics + .ICE_CANDIDATE_GATHERING_REPORTED_DURATION_, + value); +}; diff --git a/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection_test.js b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection_test.js new file mode 100644 index 00000000000..bd87b43971d --- /dev/null +++ b/chromium/chrome/browser/resources/media_router/extension/src/webrtc/peer_connection_test.js @@ -0,0 +1,215 @@ +// 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. + +/** + * @fileoverview Tests for peer_connection. + */ +goog.setTestOnly('peer_connection_test'); + +goog.require('mr.webrtc.PeerConnection'); + +describe('mr.webrtc.PeerConnection', function() { + let peerConnection; + let mockWebkitPeerConnection, mockDataChannel; + let mockOnConnectionSuccess, mockOnConnectionStale; + let mockOnConnectionClosed, mockOnConnectionFailure; + let mockOnDescriptionFn, mockOnDataChannelMessage; + let mockClock; + + const DATA_CHANNEL_NAME = 'TEST_DATA_CHANNEL'; + + beforeEach(function() { + mockOnDescriptionFn = jasmine.createSpy('mockOnDescriptionFn'); + mockOnConnectionSuccess = jasmine.createSpy('mockOnConnectionSuccess'); + mockOnConnectionClosed = jasmine.createSpy('mockOnConnectionClosed'); + mockOnConnectionFailure = jasmine.createSpy('mockOnConnectionFailure'); + mockOnConnectionStale = jasmine.createSpy('mockOnConnectionStale'); + mockOnDataChannelMessage = jasmine.createSpy('mockOnDataChannelMessage'); + // There seems to no longer be a prototype exposed for + // webkitRTCPeerConnection as of cr43. Likely duplicate of b/19817649 (and + // related). So create a dumb Jasmine mock object with all methods we care + // about. + mockWebkitPeerConnection = jasmine.createSpyObj('peerConnection', [ + 'close', 'createOffer', 'createAnswer', 'createDataChannel', 'addStream', + 'setLocalDescription', 'setRemoteDescription', 'addIceCandidate', + 'removeStream', 'oniceconnected', 'onicecompleted', 'onicefailed', + 'onicecandidate', 'oniceconnectionstatechange' + ]); + mockDataChannel = + jasmine.createSpyObj('dataChannel', ['onmessage', 'send']); + mockWebkitPeerConnection.createDataChannel.and.returnValue(mockDataChannel); + webkitRTCPeerConnection = function(config) { + return mockWebkitPeerConnection; + }; + peerConnection = new mr.webrtc.PeerConnection(DATA_CHANNEL_NAME); + peerConnection.setOnConnectionSuccess(mockOnConnectionSuccess); + peerConnection.setOnConnectionClosed(mockOnConnectionClosed); + peerConnection.setOnConnectionFailure(mockOnConnectionFailure); + peerConnection.setOnConnectionStale(mockOnConnectionStale); + peerConnection.setOnOfferDescriptionReady(mockOnDescriptionFn); + }); + + it('constructor creates webkit peer connection with data channel, ' + + 'does not start', + function() { + expect(peerConnection.isStarted()).toEqual(false); + expect(peerConnection.peerConnection_).toEqual(mockWebkitPeerConnection); + expect(peerConnection.dataChannel_).toEqual(mockDataChannel); + expect(mockWebkitPeerConnection.createDataChannel) + .toHaveBeenCalledWith(DATA_CHANNEL_NAME, {'reliable': false}); + }); + + it('data channel onmessage calls callback', function() { + peerConnection.setOnDataChannelMessage(mockOnDataChannelMessage); + const event = {'data': 'DATA!!!'}; + mockDataChannel.onmessage(event); + expect(mockOnDataChannelMessage).toHaveBeenCalledWith(event.data); + }); + + it('sendDataChannelMessage sends message via data channel', function() { + // String message. + const stringMessage = 'String message!'; + peerConnection.sendDataChannelMessage(stringMessage); + expect(mockDataChannel.send).toHaveBeenCalledWith(stringMessage); + + // Object message. + const objMessage = {'obj': 'message'}; + peerConnection.sendDataChannelMessage(objMessage); + expect(mockDataChannel.send) + .toHaveBeenCalledWith(JSON.stringify(objMessage)); + }); + + it('start creates offer, sets local description and calls on description ' + + 'callback message when ready', + function(done) { + peerConnection.start(); + + expect(peerConnection.isStarted()).toEqual(true); + expect(mockWebkitPeerConnection.createOffer).toHaveBeenCalled(); + const createOfferArgs = + mockWebkitPeerConnection.createOffer.calls.mostRecent().args; + expect(createOfferArgs[2]) + .toEqual(mr.webrtc.PeerConnection.MEDIA_CONSTRAINTS); + + // Now call the local description callback (first arg) + const description = {'sdp': 'SDP!', 'type': 'TYPE!'}; + createOfferArgs[0](description); + const localDescriptionArgs = + mockWebkitPeerConnection.setLocalDescription.calls.mostRecent().args; + expect(localDescriptionArgs[0]).toEqual(description); + + // Now trigger the message sending by triggering ICE complete. + const webkitLocalDescription = { + 'sdp': 'Local SDP with ICE candidates!', + 'type': 'Local Type!' + }; + mockWebkitPeerConnection.localDescription = webkitLocalDescription; + peerConnection.onIceCandidate_({ + 'candidate': null // empty candidate signifies that it's done. + }); + mockOnDescriptionFn.and.callFake(arg => { + expect(arg).toEqual(webkitLocalDescription); + done(); + }); + }); + + it('stop closes the webkit peer connection', function() { + peerConnection.started_ = true; + peerConnection.stop(); + + expect(peerConnection.isStarted()).toEqual(false); + expect(mockWebkitPeerConnection.close).toHaveBeenCalled(); + }); + + it('addStream adds the stream to the webkit peer connection', function() { + const stream = {'fake': 'media stream'}; + peerConnection.addStream(stream); + + expect(mockWebkitPeerConnection.addStream).toHaveBeenCalledWith(stream); + }); + + it('removeStream removes the stream from the webkit peer connection', + function() { + const stream = {'fake': 'media stream'}; + peerConnection.started_ = true; + peerConnection.removeStream(stream); + + expect(mockWebkitPeerConnection.removeStream) + .toHaveBeenCalledWith(stream); + }); + + it('setRemoteDescription sets remote description', function() { + const description = {'sdp': 'SDP!', 'type': 'TYPE!'}; + const mockRtcSessionDescription = {'sdp': 'RTC SDP!', 'type': 'RTC TYPE!'}; + spyOn(window, 'RTCSessionDescription') + .and.returnValue(mockRtcSessionDescription); + + peerConnection.setRemoteDescription(description); + + const args = + mockWebkitPeerConnection.setRemoteDescription.calls.mostRecent().args; + expect(args[0]).toEqual(mockRtcSessionDescription); + }); + + it('onIceGatheringStateChange_ resolves the session description', done => { + const description = {'sdp': 'SDP!', 'type': 'TYPE!'}; + mockWebkitPeerConnection.iceGatheringState = 'completed'; + mockWebkitPeerConnection.localDescription = description; + peerConnection.onIceGatheringStateChange_(); + + peerConnection.sessionDescriptionResolver_.promise.then(value => { + expect(value).toEqual(description); + done(); + }); + }); + + it('onIceConnectionStateChange_ calls success callback when connected', + function() { + mockWebkitPeerConnection.iceConnectionState = 'connected'; + peerConnection.onIceConnectionStateChange_({}); + expect(mockOnConnectionSuccess) + .toHaveBeenCalledWith(mr.webrtc.PeerConnection.Event.ICE_CONNECTED); + + mockOnConnectionSuccess.calls.reset(); + mockWebkitPeerConnection.iceConnectionState = 'completed'; + peerConnection.onIceConnectionStateChange_({}); + expect(mockOnConnectionSuccess) + .toHaveBeenCalledWith(mr.webrtc.PeerConnection.Event.ICE_COMPLETED); + }); + + it('onConnectionStateChange_ calls closed callback when connection closes', + function() { + mockWebkitPeerConnection.iceConnectionState = 'closed'; + peerConnection.onIceConnectionStateChange_({}); + expect(mockOnConnectionClosed) + .toHaveBeenCalledWith(mr.webrtc.PeerConnection.Event.ICE_CLOSED); + }); + + it('onConnectionStateChange_ calls failure callback when connection fails', + function() { + mockWebkitPeerConnection.iceConnectionState = 'failed'; + peerConnection.onIceConnectionStateChange_({}); + expect(mockOnConnectionFailure) + .toHaveBeenCalledWith(mr.webrtc.PeerConnection.Event.ICE_FAILED); + }); + + it('onIceConnectionStateChange_ tries to re-connect when disconnected', + function() { + peerConnection.started_ = true; + peerConnection.enableIceRestart_ = true; + + mockWebkitPeerConnection.iceConnectionState = 'disconnected'; + peerConnection.onIceConnectionStateChange_({}); + + const args = + mockWebkitPeerConnection.createOffer.calls.mostRecent().args; + expect(args[2]).toEqual( + mr.webrtc.PeerConnection.ICE_RESTART_MEDIA_CONSTRAINTS); + + // Instead, try when enableIceRestart is false. + peerConnection.enableIceRestart_ = false; + peerConnection.onIceConnectionStateChange_({}); + expect(mockOnConnectionStale).toHaveBeenCalled(); + }); +}); diff --git a/chromium/chrome/browser/resources/media_router/media_router_ui_interface.js b/chromium/chrome/browser/resources/media_router/media_router_ui_interface.js index 8cd1ca3406d..2c1bce1a3ad 100644 --- a/chromium/chrome/browser/resources/media_router/media_router_ui_interface.js +++ b/chromium/chrome/browser/resources/media_router/media_router_ui_interface.js @@ -16,6 +16,9 @@ cr.define('media_router.ui', function() { // The route-controls element. Is null if the route details view isn't open. var routeControls = null; + // The initial height for |container|. + var initialMaxHeight = 0; + /** * Handles response of previous create route attempt. * @@ -64,6 +67,11 @@ cr.define('media_router.ui', function() { function setElements(mediaRouterContainer, mediaRouterHeader) { container = mediaRouterContainer; header = mediaRouterHeader; + + if (initialMaxHeight) { + container.updateMaxDialogHeight(initialMaxHeight); + initialMaxHeight = 0; + } } /** @@ -183,7 +191,12 @@ cr.define('media_router.ui', function() { * @param {number} height */ function updateMaxHeight(height) { - container.updateMaxDialogHeight(height); + if (container) { + container.updateMaxDialogHeight(height); + } else { + // Update the max height once |container| gets set. + initialMaxHeight = height; + } } /** diff --git a/chromium/chrome/browser/resources/net_internals/http_cache_view.html b/chromium/chrome/browser/resources/net_internals/http_cache_view.html index e2c45132725..9af570e3229 100644 --- a/chromium/chrome/browser/resources/net_internals/http_cache_view.html +++ b/chromium/chrome/browser/resources/net_internals/http_cache_view.html @@ -1,8 +1,4 @@ <div id=http-cache-view-tab-content class=content-box> - <div class="hide-when-not-capturing"> - <a href="chrome://view-http-cache" target=_blank>Explore cache entries</a> - </div> - <h4>Statistics</h4> <div id=http-cache-view-cache-stats>Nothing loaded yet.</div> </div> diff --git a/chromium/chrome/browser/resources/ntp4/new_tab.js b/chromium/chrome/browser/resources/ntp4/new_tab.js index c6564c4f062..98066bfe562 100644 --- a/chromium/chrome/browser/resources/ntp4/new_tab.js +++ b/chromium/chrome/browser/resources/ntp4/new_tab.js @@ -284,7 +284,8 @@ cr.define('ntp', function() { var headerContainer = $('login-status-header-container'); headerContainer.classList.toggle('login-status-icon', !!iconURL); - headerContainer.style.backgroundImage = iconURL ? url(iconURL) : 'none'; + headerContainer.style.backgroundImage = + iconURL ? getUrlForCss(iconURL) : 'none'; } if (shouldShowLoginBubble) { diff --git a/chromium/chrome/browser/resources/offline_pages/offline_internals.html b/chromium/chrome/browser/resources/offline_pages/offline_internals.html index 013cfd09390..f7ab768f209 100644 --- a/chromium/chrome/browser/resources/offline_pages/offline_internals.html +++ b/chromium/chrome/browser/resources/offline_pages/offline_internals.html @@ -58,24 +58,22 @@ <table class="stored-pages-table"> <thead> <tr> - <th> </th> + <th>#</th> + <th></th> <th>URL</th> <th>Namespace</th> <th>Size (Kb)</th> - <th>Expired</th> - <th>Request Origin</th> </tr> </thead> <tbody id="stored-pages"> </tbody> </table> <template id="stored-pages-table-row"> <tr> + <td></td> <td><input type="checkbox" name="stored"></td> <td><a></a></td> <td></td> <td></td> - <td></td> - <td></td> </tr> </template> <div id="page-actions-info" class="dump"></div> diff --git a/chromium/chrome/browser/resources/offline_pages/offline_internals.js b/chromium/chrome/browser/resources/offline_pages/offline_internals.js index d9034ccda18..f184c8c005b 100644 --- a/chromium/chrome/browser/resources/offline_pages/offline_internals.js +++ b/chromium/chrome/browser/resources/offline_pages/offline_internals.js @@ -35,18 +35,27 @@ cr.define('offlineInternals', function() { var template = $('stored-pages-table-row'); var td = template.content.querySelectorAll('td'); - for (let page of pages) { - var checkbox = td[0].querySelector('input'); + for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { + var page = pages[pageIndex]; + td[0].textContent = pageIndex; + var checkbox = td[1].querySelector('input'); checkbox.setAttribute('value', page.id); - var link = td[1].querySelector('a'); + var link = td[2].querySelector('a'); link.setAttribute('href', page.onlineUrl); - link.textContent = page.onlineUrl; + var maxUrlCharsPerLine = 50; + if (page.onlineUrl.length > maxUrlCharsPerLine) { + link.textContent = ''; + for (let i = 0; i < page.onlineUrl.length; i += maxUrlCharsPerLine) { + link.textContent += page.onlineUrl.slice(i, i + maxUrlCharsPerLine); + link.textContent += '\r\n'; + } + } else { + link.textContent = page.onlineUrl; + } - td[2].textContent = page.namespace; - td[3].textContent = Math.round(page.size / 1024); - td[4].textContent = page.isExpired; - td[5].textContent = page.requestOrigin; + td[3].textContent = page.namespace; + td[4].textContent = Math.round(page.size / 1024); var row = document.importNode(template.content, true); storedPagesTable.appendChild(row); diff --git a/chromium/chrome/browser/resources/pdf/elements/viewer-bookmark/viewer-bookmark.html b/chromium/chrome/browser/resources/pdf/elements/viewer-bookmark/viewer-bookmark.html index 7320212941e..174b498e017 100644 --- a/chromium/chrome/browser/resources/pdf/elements/viewer-bookmark/viewer-bookmark.html +++ b/chromium/chrome/browser/resources/pdf/elements/viewer-bookmark/viewer-bookmark.html @@ -8,8 +8,8 @@ <template> <style> #item { - @apply(--layout-center); - @apply(--layout-horizontal); + @apply --layout-center; + @apply --layout-horizontal; cursor: pointer; height: 30px; position: relative; diff --git a/chromium/chrome/browser/resources/pdf/elements/viewer-page-selector/viewer-page-selector.html b/chromium/chrome/browser/resources/pdf/elements/viewer-page-selector/viewer-page-selector.html index 8ada7527043..a2c53b9ee6d 100644 --- a/chromium/chrome/browser/resources/pdf/elements/viewer-page-selector/viewer-page-selector.html +++ b/chromium/chrome/browser/resources/pdf/elements/viewer-page-selector/viewer-page-selector.html @@ -15,10 +15,9 @@ } #pageselector { - --container: { visibility: hidden; }; + --container: { display: none; }; --paper-input-container-underline: var(--container); --paper-input-container-underline-focus: var(--container); - display: inline-block; padding: 0; width: 1ch; } @@ -26,6 +25,7 @@ #input { -webkit-margin-start: -3px; color: #fff; + height: 100%; line-height: 18px; padding: 3px; text-align: end; @@ -43,10 +43,19 @@ } #pagelength-spacer { - display: inline-block; + -webkit-margin-start: -2px; + padding-bottom: 1px; text-align: start; } + #pageselector, + #slash, + #pagelength-spacer { + display: inline-block; + margin-bottom: 2px; + vertical-align: middle; + } + #input, #slash, #pagelength { @@ -54,7 +63,7 @@ } </style> <paper-input-container id="pageselector" no-label-float> - <input id="input" is="iron-input" value="{{pageNo}}" + <input id="input" is="iron-input" value="{{pageNo}}" slot="input" prevent-invalid-input allowed-pattern="\d" on-mouseup="select" on-change="pageNoCommitted" aria-label$="{{strings.labelPageNumber}}"> </paper-input-container> diff --git a/chromium/chrome/browser/resources/pdf/elements/viewer-pdf-toolbar/viewer-pdf-toolbar.html b/chromium/chrome/browser/resources/pdf/elements/viewer-pdf-toolbar/viewer-pdf-toolbar.html index 1d5c1ad9bb5..af50fd9f2ab 100644 --- a/chromium/chrome/browser/resources/pdf/elements/viewer-pdf-toolbar/viewer-pdf-toolbar.html +++ b/chromium/chrome/browser/resources/pdf/elements/viewer-pdf-toolbar/viewer-pdf-toolbar.html @@ -28,7 +28,7 @@ } #title { - @apply(--layout-flex-5); + @apply --layout-flex-5; font-size: 0.87rem; font-weight: 500; overflow: hidden; @@ -37,7 +37,7 @@ } #pageselector-container { - @apply(--layout-flex-1); + @apply --layout-flex-1; text-align: center; /* The container resizes according to the width of the toolbar. On small * screens with large numbers of pages, overflow page numbers without @@ -46,7 +46,7 @@ } #buttons { - @apply(--layout-flex-5); + @apply --layout-flex-5; text-align: end; user-select: none; } @@ -68,7 +68,7 @@ } #toolbar { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background-color: rgb(50, 54, 57); color: rgb(241, 241, 241); display: flex; diff --git a/chromium/chrome/browser/resources/pdf/elements/viewer-toolbar-dropdown/viewer-toolbar-dropdown.html b/chromium/chrome/browser/resources/pdf/elements/viewer-toolbar-dropdown/viewer-toolbar-dropdown.html index 2e3e6182753..95d75885720 100644 --- a/chromium/chrome/browser/resources/pdf/elements/viewer-toolbar-dropdown/viewer-toolbar-dropdown.html +++ b/chromium/chrome/browser/resources/pdf/elements/viewer-toolbar-dropdown/viewer-toolbar-dropdown.html @@ -26,7 +26,7 @@ } #dropdown { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background-color: rgb(256, 256, 256); border-radius: 4px; color: var(--primary-text-color); diff --git a/chromium/chrome/browser/resources/pdf/elements/viewer-zoom-toolbar/viewer-zoom-button.html b/chromium/chrome/browser/resources/pdf/elements/viewer-zoom-toolbar/viewer-zoom-button.html index 83d3d8df7f0..28ed7680a35 100644 --- a/chromium/chrome/browser/resources/pdf/elements/viewer-zoom-toolbar/viewer-zoom-button.html +++ b/chromium/chrome/browser/resources/pdf/elements/viewer-zoom-toolbar/viewer-zoom-button.html @@ -21,7 +21,7 @@ } paper-fab { - @apply(--shadow-elevation-4dp); + @apply --shadow-elevation-4dp; --paper-fab-keyboard-focus-background: var(--viewer-icon-ink-color); --paper-fab-mini: { height: 36px; diff --git a/chromium/chrome/browser/resources/pdf/open_pdf_params_parser.js b/chromium/chrome/browser/resources/pdf/open_pdf_params_parser.js index e573656c3fd..5903e403366 100644 --- a/chromium/chrome/browser/resources/pdf/open_pdf_params_parser.js +++ b/chromium/chrome/browser/resources/pdf/open_pdf_params_parser.js @@ -2,25 +2,28 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -var OpenPDFParamsParser; - (function() { 'use strict'; /** - * Creates a new OpenPDFParamsParser. This parses the open pdf parameters - * passed in the url to set initial viewport settings for opening the pdf. - * @param {!Function} getNamedDestinationsFunction The function called to fetch - * the page number for a named destination. - * @constructor + * Parses the open pdf parameters passed in the url to set initial viewport + * settings for opening the pdf. */ -OpenPDFParamsParser = function(getNamedDestinationsFunction) { - this.outstandingRequests_ = []; - this.getNamedDestinationsFunction_ = getNamedDestinationsFunction; -}; +window.OpenPDFParamsParser = class { + /** + * Constructor. + * @param {function(Object)} postMessageCallback + * Function called to fetch information for a named destination. + */ + constructor(postMessageCallback) { + /** @private {!Array<!Object>} */ + this.outstandingRequests_ = []; + + /** @private {!function(Object)} */ + this.postMessageCallback_ = postMessageCallback; + } -OpenPDFParamsParser.prototype = { /** * @private * Parse zoom parameter of open PDF parameters. The PDF should be opened at @@ -28,14 +31,14 @@ OpenPDFParamsParser.prototype = { * @param {string} paramValue zoom value. * @return {Object} Map with zoom parameters (zoom and position). */ - parseZoomParam_: function(paramValue) { - var paramValueSplit = paramValue.split(','); + parseZoomParam_(paramValue) { + const paramValueSplit = paramValue.split(','); if (paramValueSplit.length != 1 && paramValueSplit.length != 3) return {}; // User scale of 100 means zoom value of 100% i.e. zoom factor of 1.0. - var zoomFactor = parseFloat(paramValueSplit[0]) / 100; - if (isNaN(zoomFactor)) + const zoomFactor = parseFloat(paramValueSplit[0]) / 100; + if (Number.isNaN(zoomFactor)) return {}; // Handle #zoom=scale. @@ -44,12 +47,12 @@ OpenPDFParamsParser.prototype = { } // Handle #zoom=scale,left,top. - var position = { + const position = { x: parseFloat(paramValueSplit[1]), y: parseFloat(paramValueSplit[2]) }; return {'position': position, 'zoom': zoomFactor}; - }, + } /** * @private @@ -58,14 +61,14 @@ OpenPDFParamsParser.prototype = { * @param {string} paramValue view value. * @return {Object} Map with view parameters (view and viewPosition). */ - parseViewParam_: function(paramValue) { - var viewModeComponents = paramValue.toLowerCase().split(','); + parseViewParam_(paramValue) { + const viewModeComponents = paramValue.toLowerCase().split(','); if (viewModeComponents.length < 1) return {}; - var params = {}; - var viewMode = viewModeComponents[0]; - var acceptsPositionParam; + const params = {}; + const viewMode = viewModeComponents[0]; + let acceptsPositionParam; if (viewMode === 'fit') { params['view'] = FittingType.FIT_TO_PAGE; acceptsPositionParam = false; @@ -80,12 +83,12 @@ OpenPDFParamsParser.prototype = { if (!acceptsPositionParam || viewModeComponents.length < 2) return params; - var position = parseFloat(viewModeComponents[1]); - if (!isNaN(position)) + const position = parseFloat(viewModeComponents[1]); + if (!Number.isNaN(position)) params['viewPosition'] = position; return params; - }, + } /** * Parse the parameters encoded in the fragment of a URL into a dictionary. @@ -93,14 +96,14 @@ OpenPDFParamsParser.prototype = { * @param {string} url to parse * @return {Object} Key-value pairs of URL parameters */ - parseUrlParams_: function(url) { - var params = {}; + parseUrlParams_(url) { + const params = {}; - var paramIndex = url.search('#'); + const paramIndex = url.search('#'); if (paramIndex == -1) return params; - var paramTokens = url.substring(paramIndex + 1).split('&'); + const paramTokens = url.substring(paramIndex + 1).split('&'); if ((paramTokens.length == 1) && (paramTokens[0].search('=') == -1)) { // Handle the case of http://foo.com/bar#NAMEDDEST. This is not // explicitly mentioned except by example in the Adobe @@ -109,15 +112,15 @@ OpenPDFParamsParser.prototype = { return params; } - for (var i = 0; i < paramTokens.length; ++i) { - var keyValueSplit = paramTokens[i].split('='); + for (let paramToken of paramTokens) { + const keyValueSplit = paramToken.split('='); if (keyValueSplit.length != 2) continue; params[keyValueSplit[0]] = keyValueSplit[1]; } return params; - }, + } /** * Parse PDF url parameters used for controlling the state of UI. These need @@ -126,15 +129,15 @@ OpenPDFParamsParser.prototype = { * @param {string} url that needs to be parsed. * @return {Object} parsed url parameters. */ - getUiUrlParams: function(url) { - var params = this.parseUrlParams_(url); - var uiParams = {toolbar: true}; + getUiUrlParams(url) { + const params = this.parseUrlParams_(url); + const uiParams = {toolbar: true}; if ('toolbar' in params && params['toolbar'] == 0) uiParams.toolbar = false; return uiParams; - }, + } /** * Parse PDF url parameters. These parameters are mentioned in the url @@ -144,16 +147,16 @@ OpenPDFParamsParser.prototype = { * @param {string} url that needs to be parsed. * @param {Function} callback function to be called with viewport info. */ - getViewportFromUrlParams: function(url, callback) { - var params = {}; + getViewportFromUrlParams(url, callback) { + const params = {}; params['url'] = url; - var urlParams = this.parseUrlParams_(url); + const urlParams = this.parseUrlParams_(url); if ('page' in urlParams) { // |pageNumber| is 1-based, but goToPage() take a zero-based page number. - var pageNumber = parseInt(urlParams['page'], 10); - if (!isNaN(pageNumber) && pageNumber > 0) + const pageNumber = parseInt(urlParams['page'], 10); + if (!Number.isNaN(pageNumber) && pageNumber > 0) params['page'] = pageNumber - 1; } @@ -165,11 +168,14 @@ OpenPDFParamsParser.prototype = { if (params.page === undefined && 'nameddest' in urlParams) { this.outstandingRequests_.push({callback: callback, params: params}); - this.getNamedDestinationsFunction_(urlParams['nameddest']); + this.postMessageCallback_({ + type: 'getNamedDestination', + namedDestination: urlParams['nameddest'] + }); } else { callback(params); } - }, + } /** * This is called when a named destination is received and the page number @@ -177,12 +183,12 @@ OpenPDFParamsParser.prototype = { * @param {number} pageNumber The page corresponding to the named destination * requested. */ - onNamedDestinationReceived: function(pageNumber) { - var outstandingRequest = this.outstandingRequests_.shift(); + onNamedDestinationReceived(pageNumber) { + const outstandingRequest = this.outstandingRequests_.shift(); if (pageNumber != -1) outstandingRequest.params.page = pageNumber; outstandingRequest.callback(outstandingRequest.params); - }, + } }; }()); diff --git a/chromium/chrome/browser/resources/pdf/pdf.js b/chromium/chrome/browser/resources/pdf/pdf.js index 4a8cb93199e..029f5b231af 100644 --- a/chromium/chrome/browser/resources/pdf/pdf.js +++ b/chromium/chrome/browser/resources/pdf/pdf.js @@ -106,15 +106,20 @@ function PDFViewer(browserApi) { this.isUserInitiatedEvent_ = true; /** - * @type {PDFMetrics} + * @type {!PDFMetrics} */ this.metrics = (chrome.metricsPrivate ? new PDFMetricsImpl() : new PDFMetricsDummy()); this.metrics.onDocumentOpened(); + /** + * @private {!PDFCoordsTransformer} + */ + this.coordsTransformer_ = + new PDFCoordsTransformer(this.postMessage_.bind(this)); + // Parse open pdf parameters. - this.paramsParser_ = - new OpenPDFParamsParser(this.getNamedDestination_.bind(this)); + this.paramsParser_ = new OpenPDFParamsParser(this.postMessage_.bind(this)); var toolbarEnabled = this.paramsParser_.getUiUrlParams(this.originalUrl_).toolbar && !this.isPrintPreview_; @@ -224,9 +229,6 @@ function PDFViewer(browserApi) { this.toolbar_.docTitle = getFilenameFromURL(this.originalUrl_); } - this.coordsTransformer_ = - new PDFCoordsTransformer(this.plugin_.postMessage.bind(this.plugin_)); - document.body.addEventListener('change-page', e => { this.viewport_.goToPage(e.detail.page); if (e.detail.origin == 'bookmark') @@ -391,7 +393,7 @@ PDFViewer.prototype = { return; case 65: // 'a' key. if (e.ctrlKey || e.metaKey) { - this.plugin_.postMessage({type: 'selectAll'}); + this.postMessage_({type: 'selectAll'}); // Since we do selection ourselves. e.preventDefault(); } @@ -451,7 +453,7 @@ PDFViewer.prototype = { */ rotateClockwise_: function() { this.metrics.onRotation(); - this.plugin_.postMessage({type: 'rotateClockwise'}); + this.postMessage_({type: 'rotateClockwise'}); }, /** @@ -460,7 +462,7 @@ PDFViewer.prototype = { */ rotateCounterClockwise_: function() { this.metrics.onRotation(); - this.plugin_.postMessage({type: 'rotateCounterclockwise'}); + this.postMessage_({type: 'rotateCounterclockwise'}); }, /** @@ -488,7 +490,7 @@ PDFViewer.prototype = { * Notify the plugin to print. */ print_: function() { - this.plugin_.postMessage({type: 'print'}); + this.postMessage_({type: 'print'}); }, /** @@ -496,17 +498,7 @@ PDFViewer.prototype = { * Notify the plugin to save. */ save_: function() { - this.plugin_.postMessage({type: 'save'}); - }, - - /** - * Fetches the page number corresponding to the given named destination from - * the plugin. - * @param {string} name The namedDestination to fetch page number from plugin. - */ - getNamedDestination_: function(name) { - this.plugin_.postMessage( - {type: 'getNamedDestination', namedDestination: name}); + this.postMessage_({type: 'save'}); }, /** @@ -630,12 +622,22 @@ PDFViewer.prototype = { * @param {Object} event a password-submitted event. */ onPasswordSubmitted_: function(event) { - this.plugin_.postMessage( + this.postMessage_( {type: 'getPasswordComplete', password: event.detail.password}); }, /** * @private + * Post a message to the PPAPI plugin. Some messages will cause an async reply + * to be received through handlePluginMessage_(). + * @param {Object} message Message to post. + */ + postMessage_: function(message) { + this.plugin_.postMessage(message); + }, + + /** + * @private * An event handler for handling message events received from the plugin. * @param {MessageObject} message a message event. */ @@ -747,13 +749,13 @@ PDFViewer.prototype = { * reacting to scroll events while zoom is taking place to avoid flickering. */ beforeZoom_: function() { - this.plugin_.postMessage({type: 'stopScrolling'}); + this.postMessage_({type: 'stopScrolling'}); if (this.viewport_.pinchPhase == Viewport.PinchPhase.PINCH_START) { var position = this.viewport_.position; var zoom = this.viewport_.zoom; var pinchPhase = this.viewport_.pinchPhase; - this.plugin_.postMessage({ + this.postMessage_({ type: 'viewport', userInitiated: true, zoom: zoom, @@ -776,7 +778,7 @@ PDFViewer.prototype = { var pinchCenter = this.viewport_.pinchCenter || {x: 0, y: 0}; var pinchPhase = this.viewport_.pinchPhase; - this.plugin_.postMessage({ + this.postMessage_({ type: 'viewport', userInitiated: this.isUserInitiatedEvent_, zoom: zoom, @@ -935,7 +937,7 @@ PDFViewer.prototype = { case 'getSelectedText': case 'print': case 'selectAll': - this.plugin_.postMessage(message.data); + this.postMessage_(message.data); break; } }, @@ -952,7 +954,7 @@ PDFViewer.prototype = { switch (message.data.type.toString()) { case 'loadPreviewPage': - this.plugin_.postMessage(message.data); + this.postMessage_(message.data); return true; case 'resetPrintPreviewMode': this.loadState_ = LoadState.LOADING; @@ -977,7 +979,7 @@ PDFViewer.prototype = { this.pageIndicator_.pageLabels = message.data.pageNumbers; - this.plugin_.postMessage({ + this.postMessage_({ type: 'resetPrintPreviewMode', url: message.data.url, grayscale: message.data.grayscale, diff --git a/chromium/chrome/browser/resources/plugin_metadata/plugins_linux.json b/chromium/chrome/browser/resources/plugin_metadata/plugins_linux.json index 9d4b50b6b41..d00ba641dd8 100644 --- a/chromium/chrome/browser/resources/plugin_metadata/plugins_linux.json +++ b/chromium/chrome/browser/resources/plugin_metadata/plugins_linux.json @@ -1,5 +1,5 @@ { - "x-version": 28, + "x-version": 29, "google-talk": { "mime_types": [ ], @@ -80,9 +80,9 @@ ], "versions": [ { - "version": "28.0.0.161", + "version": "29.0.0.140", "status": "up_to_date", - "reference": "https://helpx.adobe.com/security/products/flash-player/apsb18-03.html" + "reference": "https://helpx.adobe.com/security/products/flash-player/apsb18-08.html" } ], "lang": "en-US", diff --git a/chromium/chrome/browser/resources/plugin_metadata/plugins_mac.json b/chromium/chrome/browser/resources/plugin_metadata/plugins_mac.json index 5a7f063708f..a4feba50406 100644 --- a/chromium/chrome/browser/resources/plugin_metadata/plugins_mac.json +++ b/chromium/chrome/browser/resources/plugin_metadata/plugins_mac.json @@ -1,5 +1,5 @@ { - "x-version": 34, + "x-version": 35, "google-talk": { "mime_types": [ ], @@ -115,9 +115,9 @@ ], "versions": [ { - "version": "28.0.0.161", + "version": "29.0.0.140", "status": "requires_authorization", - "reference": "https://helpx.adobe.com/security/products/flash-player/apsb18-03.html" + "reference": "https://helpx.adobe.com/security/products/flash-player/apsb18-08.html" } ], "lang": "en-US", diff --git a/chromium/chrome/browser/resources/plugin_metadata/plugins_win.json b/chromium/chrome/browser/resources/plugin_metadata/plugins_win.json index 4d356b256f0..45880e4aefa 100644 --- a/chromium/chrome/browser/resources/plugin_metadata/plugins_win.json +++ b/chromium/chrome/browser/resources/plugin_metadata/plugins_win.json @@ -1,5 +1,5 @@ { - "x-version": 43, + "x-version": 44, "google-talk": { "mime_types": [ ], @@ -137,9 +137,9 @@ ], "versions": [ { - "version": "28.0.0.161", + "version": "29.0.0.140", "status": "requires_authorization", - "reference": "https://helpx.adobe.com/security/products/flash-player/apsb18-03.html" + "reference": "https://helpx.adobe.com/security/products/flash-player/apsb18-08.html" } ], "lang": "en-US", diff --git a/chromium/chrome/browser/resources/policy.css b/chromium/chrome/browser/resources/policy.css index d9b74acf519..45df8882782 100644 --- a/chromium/chrome/browser/resources/policy.css +++ b/chromium/chrome/browser/resources/policy.css @@ -36,11 +36,6 @@ html[dir='rtl'] div.left-aligned-button { float: right; } -div.chrome-for-work { - -webkit-padding-start: 25px; - display: inline-block; -} - section.status-box-section { clear: both; }
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/policy.html b/chromium/chrome/browser/resources/policy.html index e03b6758dd7..93a74fdf41d 100644 --- a/chromium/chrome/browser/resources/policy.html +++ b/chromium/chrome/browser/resources/policy.html @@ -36,10 +36,6 @@ <div class="left-aligned-button"> <button id="export-policies">$i18n{exportPoliciesJSON}</button> </div> - <div class="chrome-for-work"> - <a href="http://g.co/chromeent/learn" target="_blank"> - <span>$i18n{chromeForWork}</span></a> - </div> <div id="show-unset-container" class="show-unset-checkbox"> <label> <input id="show-unset" type="checkbox"> diff --git a/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.html b/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.html index ad8c3a7cae5..61edce46557 100644 --- a/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.html +++ b/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.html @@ -1,50 +1,4 @@ <div id='rpp_enabled'> - <tabbox id="rpp_data"> - <tabs> - <tab>URL Table Cache</tab> - <tab>Host Table Cache</tab> - <tab>Origin Table Cache</tab> - </tabs> - <tabpanels> - <tabpanel> - <table> - <thead> - <tr> - <th>Main Frame Url</th> - <th>Resource Url</th> - <th>Resource Type</th> - <th>Num Hits</th> - <th>Num Misses</th> - <th>Consec Misses</th> - <th>Average Position</th> - <th>Score</th> - <th>Before FCP</th> - </tr> - </thead> - <tbody id="rpp_url_body"> - </tbody> - </table> - </tabpanel> - <tabpanel> - <table> - <thead> - <tr> - <th>Host</th> - <th>Resource Url</th> - <th>Resource Type</th> - <th>Num Hits</th> - <th>Num Misses</th> - <th>Consec Misses</th> - <th>Average Position</th> - <th>Score</th> - <th>Before FCP</th> - </tr> - </thead> - <tbody id="rpp_host_body"> - </tbody> - </table> - </tabpanel> - <tabpanel> <table> <thead> <tr> @@ -62,9 +16,6 @@ <tbody id="rpp_origin_body"> </tbody> </table> - </tabpanel> - </tabpanels> - </tabbox> </div> <div id='rpp_disabled'> Resource prefetch prediction is disabled. diff --git a/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.js b/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.js index a00b7d58a20..66af67b4186 100644 --- a/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.js +++ b/chromium/chrome/browser/resources/predictors/resource_prefetch_predictor.js @@ -45,62 +45,13 @@ function updateResourcePrefetchPredictorDbView(database) { $('rpp_enabled').style.display = 'block'; $('rpp_disabled').style.display = 'none'; - var hasUrlData = database.url_db && database.url_db.length > 0; - var hasHostData = database.host_db && database.host_db.length > 0; var hasOriginData = database.origin_db && database.origin_db.length > 0; - if (hasUrlData) - renderCacheData($('rpp_url_body'), database.url_db); - if (hasHostData) - renderCacheData($('rpp_host_body'), database.host_db); if (hasOriginData) renderOriginData($('rpp_origin_body'), database.origin_db); } /** - * Renders cache data for URL or host based data. - * @param {HTMLElement} body element of table to render into. - * @param {Object} database to render. - */ -function renderCacheData(body, database) { - body.textContent = ''; - for (let main of database) { - for (var j = 0; j < main.resources.length; ++j) { - var resource = main.resources[j]; - var row = document.createElement('tr'); - - if (j == 0) { - var t = document.createElement('td'); - t.rowSpan = main.resources.length; - t.textContent = truncateString(main.main_frame_url); - row.appendChild(t); - } - - row.className = - resource.is_prefetchable ? 'action-prerender' : 'action-none'; - - row.appendChild(document.createElement('td')).textContent = - truncateString(resource.resource_url); - row.appendChild(document.createElement('td')).textContent = - resource.resource_type; - row.appendChild(document.createElement('td')).textContent = - resource.number_of_hits; - row.appendChild(document.createElement('td')).textContent = - resource.number_of_misses; - row.appendChild(document.createElement('td')).textContent = - resource.consecutive_misses; - row.appendChild(document.createElement('td')).textContent = - resource.position; - row.appendChild(document.createElement('td')).textContent = - resource.score; - row.appendChild(document.createElement('td')).textContent = - resource.before_first_contentful_paint; - body.appendChild(row); - } - } -} - -/** * Renders the content of the predictor origin table. * @param {HTMLElement} body element of table to render into. * @param {Object} database to render. diff --git a/chromium/chrome/browser/resources/print_preview/OWNERS b/chromium/chrome/browser/resources/print_preview/OWNERS index ef10fbb92d5..e542f20827d 100644 --- a/chromium/chrome/browser/resources/print_preview/OWNERS +++ b/chromium/chrome/browser/resources/print_preview/OWNERS @@ -1,5 +1,3 @@ -dpapad@chromium.org -gene@chromium.org -vitalybuka@chromium.org +file://printing/OWNERS # COMPONENT: UI>Browser>PrintPreview diff --git a/chromium/chrome/browser/resources/print_preview/cloud_print_interface.html b/chromium/chrome/browser/resources/print_preview/cloud_print_interface.html new file mode 100644 index 00000000000..9997dacb339 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/cloud_print_interface.html @@ -0,0 +1,9 @@ +<link rel="import" href="chrome://resources/html/cr.html"> +<link rel="import" href="native_layer.html"> +<link rel="import" href="data/cloud_parsers.html"> +<link rel="import" href="data/destination.html"> +<link rel="import" href="data/document_info.html"> +<link rel="import" href="data/invitation.html"> +<link rel="import" href="data/user_info.html"> + +<script src="cloud_print_interface.js"></script> diff --git a/chromium/chrome/browser/resources/print_preview/data/cloud_parsers.html b/chromium/chrome/browser/resources/print_preview/data/cloud_parsers.html new file mode 100644 index 00000000000..12f99ebed56 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/data/cloud_parsers.html @@ -0,0 +1,5 @@ +<link rel="import" href="chrome://resources/html/cr.html"> +<link rel="import" href="destination.html"> +<link rel="import" href="invitation.html"> + +<script src="cloud_parsers.js"></script> diff --git a/chromium/chrome/browser/resources/print_preview/data/destination.js b/chromium/chrome/browser/resources/print_preview/data/destination.js index 46f97789af1..a3b1e5627d0 100644 --- a/chromium/chrome/browser/resources/print_preview/data/destination.js +++ b/chromium/chrome/browser/resources/print_preview/data/destination.js @@ -71,10 +71,40 @@ print_preview.DestinationCertificateStatus = { }; /** + * @typedef {{ + * display_name: (string), + * type: (string | undefined), + * value: (number | string | boolean), + * is_default: (boolean | undefined), + * }} + */ +print_preview.VendorCapabilitySelectOption; + +/** + * Specifies a custom vendor capability. + * @typedef {{ + * id: (string), + * display_name: (string), + * localized_display_name: (string | undefined), + * type: (string), + * select_cap: ({ + * option: (Array<!print_preview.VendorCapabilitySelectOption>|undefined), + * }|undefined), + * typed_value_cap: ({ + * default: (number | string | boolean | undefined), + * }|undefined), + * range_cap: ({ + * default: (number), + * }), + * }} + */ +print_preview.VendorCapability; + +/** * Capabilities of a print destination represented in a CDD. * * @typedef {{ - * vendor_capability: !Array<{Object}>, + * vendor_capability: !Array<!print_preview.VendorCapability>, * collate: ({default: (boolean|undefined)}|undefined), * color: ({ * option: !Array<{ @@ -537,10 +567,18 @@ cr.define('print_preview', function() { } /** + * @return {boolean} Whether the destination is offline or has an invalid + * certificate. + */ + get isOfflineOrInvalid() { + return this.isOffline || this.shouldShowInvalidCertificateError; + } + + /** * @return {string} Human readable status for a destination that is offline * or has a bad certificate. */ get connectionStatusText() { - if (!this.isOffline && !this.shouldShowInvalidCertificateError) + if (!this.isOfflineOrInvalid) return ''; const offlineDurationMs = Date.now() - this.lastAccessTime_; let statusMessageId; @@ -781,7 +819,6 @@ cr.define('print_preview', function() { LOCAL_2X: 'images/2x/printer.png', MOBILE: 'images/mobile.png', MOBILE_SHARED: 'images/mobile_shared.png', - THIRD_PARTY: 'images/third_party.png', PDF: 'images/pdf.png', DOCS: 'images/google_doc.png', ENTERPRISE: 'images/business.svg' diff --git a/chromium/chrome/browser/resources/print_preview/data/destination_store.html b/chromium/chrome/browser/resources/print_preview/data/destination_store.html index ff0c3f8f95c..67f7dbc63f8 100644 --- a/chromium/chrome/browser/resources/print_preview/data/destination_store.html +++ b/chromium/chrome/browser/resources/print_preview/data/destination_store.html @@ -1,7 +1,4 @@ <link rel="import" href="chrome://resources/html/cr.html"> -<link rel="import" href="chrome://resources/html/event_tracker.html"> -<link rel="import" href="chrome://resources/html/webui_listener_tracker.html"> -<link rel="import" href="chrome://resources/html/cr/event_target.html"> <link rel="import" href="../metrics.html"> <link rel="import" href="../native_layer.html"> <link rel="import" href="destination.html"> diff --git a/chromium/chrome/browser/resources/print_preview/data/invitation.html b/chromium/chrome/browser/resources/print_preview/data/invitation.html new file mode 100644 index 00000000000..0a72be7c1ea --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/data/invitation.html @@ -0,0 +1,4 @@ +<link rel="import" href="chrome://resources/html/cr.html"> +<link rel="import" href="destination.html"> + +<script src="invitation.js"></script> diff --git a/chromium/chrome/browser/resources/print_preview/images/2x/printer.png b/chromium/chrome/browser/resources/print_preview/images/2x/printer.png Binary files differindex b704e02f841..6bd2a925be3 100644 --- a/chromium/chrome/browser/resources/print_preview/images/2x/printer.png +++ b/chromium/chrome/browser/resources/print_preview/images/2x/printer.png diff --git a/chromium/chrome/browser/resources/print_preview/images/2x/printer_shared.png b/chromium/chrome/browser/resources/print_preview/images/2x/printer_shared.png Binary files differindex bbddfd04d2c..f27e672f7b9 100644 --- a/chromium/chrome/browser/resources/print_preview/images/2x/printer_shared.png +++ b/chromium/chrome/browser/resources/print_preview/images/2x/printer_shared.png diff --git a/chromium/chrome/browser/resources/print_preview/images/third_party.png b/chromium/chrome/browser/resources/print_preview/images/third_party.png Binary files differdeleted file mode 100644 index d15552d390c..00000000000 --- a/chromium/chrome/browser/resources/print_preview/images/third_party.png +++ /dev/null diff --git a/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.html b/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.html index 554ba1337a4..5ecb91c14e2 100644 --- a/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.html @@ -1,7 +1,11 @@ <link rel="import" href="chrome://resources/html/polymer.html"> +<link rel="import" href="chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.html"> +<link rel="import" href="../data/destination.html"> +<link rel="import" href="advanced_settings_dialog.html"> <link rel="import" href="button_css.html"> <link rel="import" href="print_preview_shared_css.html"> +<link rel="import" href="settings_behavior.html"> <link rel="import" href="settings_section.html"> <dom-module id="print-preview-advanced-options-settings"> @@ -15,9 +19,16 @@ <print-preview-settings-section> <span slot="title">$i18n{advancedOptionsLabel}</span> <div slot="controls"> - <button>$i18n{showAdvancedOptions}</button> + <button disabled$="[[disabled]]" on-click="onButtonClick_"> + $i18n{showAdvancedOptions} + </button> </div> </print-preview-settings-section> + <template is="cr-lazy-render" id="advancedDialog"> + <print-preview-advanced-dialog + settings="{{settings}}" destination="[[destination]]"> + </print-preview-advanced-dialog> + </template> </template> <script src="advanced_options_settings.js"></script> </dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.js b/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.js index 6df05eb6896..b47ab004d75 100644 --- a/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/advanced_options_settings.js @@ -4,4 +4,23 @@ Polymer({ is: 'print-preview-advanced-options-settings', + + behaviors: [SettingsBehavior], + + properties: { + disabled: Boolean, + + /** @type {!print_preview.Destination} */ + destination: Object, + }, + + /** @private */ + onButtonClick_: function() { + const dialog = this.$.advancedDialog.get(); + // This async() call is a workaround to prevent a DCHECK - see + // https://crbug.com/804047. + this.async(() => { + dialog.show(); + }, 1); + }, }); diff --git a/chromium/chrome/browser/resources/print_preview/new/advanced_settings_dialog.html b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_dialog.html new file mode 100644 index 00000000000..91e920b5383 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_dialog.html @@ -0,0 +1,47 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/cr_dialog/cr_dialog.html"> +<link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> +<link rel="import" href="chrome://resources/html/i18n_behavior.html"> +<link rel="import" href="../data/destination.html"> +<link rel="import" href="advanced_settings_item.html"> +<link rel="import" href="settings_behavior.html"> +<link rel="import" href="button_css.html"> +<link rel="import" href="print_preview_shared_css.html"> +<link rel="import" href="search_dialog_css.html"> + +<dom-module id="print-preview-advanced-dialog"> + <style include="print-preview-shared search-dialog button cr-hidden-style"> + </style> + <template> + <dialog is="cr-dialog" id="dialog" on-close="onCloseOrCancel_"> + <div slot="title"> + <div>[[i18n('advancedSettingsDialogTitle', destination.displayName)]] + </div> + <print-preview-search-box id="searchBox" + label="$i18n{advancedSettingsSearchBoxPlaceholder}" + search-query="{{searchQuery_}}"> + </print-preview-search-box> + </div> + <div slot="body"> + <template is="dom-repeat" + items="[[destination.capabilities.printer.vendor_capability]]"> + <print-preview-advanced-settings-item capability="[[item]]" + settings="[[settings]]"> + </print-preview-advanced-settings-item> + </template> + <div class="no-settings-match-hint" + hidden$="[[!shouldShowHint_(hasMatching_)]]"> + $i18n{noAdvancedSettingsMatchSearchHint} + </div> + </div> + <div slot="button-container"> + <button on-click="onCancelButtonClick_">$i18n{cancel}</button> + <button on-click="onApplyButtonClick_"> + $i18n{advancedSettingsDialogConfirm} + </button> + </div> + </dialog> + </template> + <script src="advanced_settings_dialog.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/advanced_settings_dialog.js b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_dialog.js new file mode 100644 index 00000000000..989de5b0285 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_dialog.js @@ -0,0 +1,82 @@ +// Copyright 2018 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. + +Polymer({ + is: 'print-preview-advanced-dialog', + + behaviors: [SettingsBehavior, I18nBehavior], + + properties: { + /** @type {!print_preview.Destination} */ + destination: Object, + + /** @private {?RegExp} */ + searchQuery_: { + type: Object, + value: null, + }, + + /** @private {boolean} */ + hasMatching_: { + type: Boolean, + notify: true, + computed: 'computeHasMatching_(searchQuery_)', + }, + }, + + /** + * @return {boolean} Whether there is a setting matching the query. + * @private + */ + computeHasMatching_: function() { + const listItems = this.shadowRoot.querySelectorAll( + 'print-preview-advanced-settings-item'); + let hasMatch = false; + listItems.forEach(item => { + const matches = item.hasMatch(this.searchQuery_); + item.hidden = !matches; + hasMatch = hasMatch || matches; + item.updateHighlighting(this.searchQuery_); + }); + return hasMatch; + }, + + /** + * @return {boolean} Whether the no matching settings hint should be shown. + * @private + */ + shouldShowHint_: function() { + return !!this.searchQuery_ && !this.hasMatching_; + }, + + /** @private */ + onCloseOrCancel_: function() { + if (this.searchQuery_) + this.$.searchBox.setValue(''); + }, + + /** @private */ + onCancelButtonClick_: function() { + this.$.dialog.cancel(); + }, + + /** @private */ + onApplyButtonClick_: function() { + const settingsValues = {}; + this.shadowRoot.querySelectorAll('print-preview-advanced-settings-item') + .forEach(item => { + settingsValues[item.capability.id] = item.getCurrentValue(); + }); + this.setSetting('vendorItems', settingsValues); + this.$.dialog.close(); + }, + + show: function() { + this.$.dialog.showModal(); + }, + + close: function() { + this.$.dialog.close(); + }, +}); diff --git a/chromium/chrome/browser/resources/print_preview/new/advanced_settings_item.html b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_item.html new file mode 100644 index 00000000000..af531c738fa --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_item.html @@ -0,0 +1,68 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/search_highlight_style_css.html"> +<link rel="import" href="../print_preview_utils.html"> +<link rel="import" href="../data/destination.html"> +<link rel="import" href="highlight_utils.html"> +<link rel="import" href="print_preview_shared_css.html"> +<link rel="import" href="select_css.html"> +<link rel="import" href="settings_behavior.html"> + +<dom-module id="print-preview-advanced-settings-item"> + <style include="print-preview-shared select search-highlight-style"> + :host { + display: flex; + position: relative; + } + + :host > * { + overflow: hidden; + padding-bottom: 15px; + padding-top: 10px; + text-overflow: ellipsis; + vertical-align: middle; + white-space: nowrap; + } + + :host .label { + -webkit-padding-end: 20px; + display: flex; + flex-direction: column; + justify-content: center; + width: 250px; + } + + :host .value { + width: 175px; + } + + :host input { + height: 28px; + line-height: 24px; + width: 175px; + } + </style> + <template> + <label class="label searchable">[[getDisplayName_(capability)]]</label> + <div class="value"> + <template is="dom-if" if="[[isCapabilityTypeSelect_(capability)]]" + restamp> + <div> + <select on-change="onUserInput_"> + <template is="dom-repeat" items="[[capability.select_cap.option]]"> + <option class="searchable" text="[[getDisplayName_(item)]]" + value="[[item.value]]" + selected="[[isOptionSelected_(item, currentValue_)]]"> + </option> + </template> + </select> + </div> + </template> + <span hidden$="[[isCapabilityTypeSelect_(capability)]]"> + <input type="text" on-input="onUserInput_" + placeholder="[[getCapabilityPlaceholder_(capability)]]"> + </span> + </div> + </template> + <script src="advanced_settings_item.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/advanced_settings_item.js b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_item.js new file mode 100644 index 00000000000..3de026d756b --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/advanced_settings_item.js @@ -0,0 +1,154 @@ +// Copyright 2018 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. + +Polymer({ + is: 'print-preview-advanced-settings-item', + + behaviors: [SettingsBehavior], + + properties: { + /** @type {!print_preview.VendorCapability} */ + capability: Object, + + /** @private {(number | string | boolean)} */ + currentValue_: { + type: Object, + value: null, + }, + }, + + observers: [ + 'updateFromSettings_(capability, settings.vendorItems.value)', + ], + + /** @private {boolean} */ + highlighted_: false, + + /** @private */ + updateFromSettings_: function() { + const settings = this.getSetting('vendorItems').value; + + // The settings may not have a property with the id if they were populated + // from sticky settings from a different destination or if the + // destination's capabilities changed since the sticky settings were + // generated. + if (!settings.hasOwnProperty(this.capability.id)) + return; + + const value = settings[this.capability.id]; + if (this.isCapabilityTypeSelect_()) { + // Ignore a value that can't be selected. + if (this.hasOptionWithValue_(value)) + this.currentValue_ = value; + } else { + this.currentValue_ = value; + this.$$('input[type="text"]').value = this.currentValue_; + } + }, + + /** + * @param {!print_preview.VendorCapability | + * !print_preview.VendorCapabilitySelectOption} item + * @return {string} The display name for the setting. + * @private + */ + getDisplayName_: function(item) { + let displayName = item.display_name; + if (!displayName && item.display_name_localized) + displayName = getStringForCurrentLocale(item.display_name_localized); + return displayName || ''; + }, + + /** + * @return {boolean} Whether the capability represented by this item is + * of type select. + * @private + */ + isCapabilityTypeSelect_: function() { + return this.capability.type == 'SELECT'; + }, + + /** + * @param {!print_preview.VendorCapabilitySelectOption} option The option + * for a select capability. + * @return {boolean} Whether the option is selected. + * @private + */ + isOptionSelected_: function(option) { + return (this.currentValue_ !== null && + option.value === this.currentValue_) || + (this.currentValue_ == null && !!option.is_default); + }, + + /** + * @return {string} The placeholder value for the capability's text input. + * @private + */ + getCapabilityPlaceholder_: function() { + if (this.capability.type == 'TYPED_VALUE' && + this.capability.typed_value_cap && + this.capability.typed_value_cap.default != undefined) { + return this.capability.typed_value_cap.default.toString() || ''; + } + if (this.capability.type == 'RANGE' && this.capability.range_cap && + this.capability.range_cap.default != undefined) + return this.capability.range_cap.default.toString() || ''; + return ''; + }, + + /** + * @return {boolean} + * @private + */ + hasOptionWithValue_: function(value) { + return !!this.capability.select_cap && + !!this.capability.select_cap.option && + this.capability.select_cap.option.some(option => option.value == value); + }, + + /** + * @param {?RegExp} query The current search query. + * @return {boolean} Whether the item has a match for the query. + */ + hasMatch: function(query) { + if (!query || this.getDisplayName_(this.capability).match(query)) + return true; + + if (!this.isCapabilityTypeSelect_()) + return false; + + for (let option of + /** @type {!Array<!print_preview.VendorCapabilitySelectOption>} */ ( + this.capability.select_cap.option)) { + if (this.getDisplayName_(option).match(query)) + return true; + } + return false; + }, + + /** + * @param {!Event} e Event containing the new value. + * @private + */ + onUserInput_: function(e) { + this.currentValue_ = e.target.value; + }, + + /** + * @return {(number | string | boolean)} The current value of the setting. + */ + getCurrentValue: function() { + return this.currentValue_; + }, + + /** + * @param {?RegExp} query The current search query. + * @return {boolean} Whether the current query is a match for this item. + */ + updateHighlighting: function(query) { + this.highlighted_ = + print_preview.updateHighlights(this, query, this.highlighted_); + return this.highlighted_ || !query; + }, +}); diff --git a/chromium/chrome/browser/resources/print_preview/new/app.html b/chromium/chrome/browser/resources/print_preview/new/app.html index fd29781eb45..dbfe5590f85 100644 --- a/chromium/chrome/browser/resources/print_preview/new/app.html +++ b/chromium/chrome/browser/resources/print_preview/new/app.html @@ -2,6 +2,7 @@ <link rel="import" href="chrome://resources/html/event_tracker.html"> <link rel="import" href="chrome://resources/html/webui_listener_tracker.html"> +<link rel="import" href="../cloud_print_interface.html"> <link rel="import" href="../native_layer.html"> <link rel="import" href="../data/destination.html"> <link rel="import" href="../data/destination_store.html"> @@ -9,6 +10,7 @@ <link rel="import" href="../data/measurement_system.html"> <link rel="import" href="../data/user_info.html"> <link rel="import" href="settings_behavior.html"> +<link rel="import" href="state.html"> <link rel="import" href="model.html"> <link rel="import" href="header.html"> <link rel="import" href="preview_area.html"> @@ -48,64 +50,82 @@ overflow: auto; } - #preview-area { + #preview-area-container { -webkit-border-start: 1px solid #dcdcdc; align-items: center; background-color: #e6e6e6; flex: 1; } </style> + <print-preview-state id="state" state="{{state}}"></print-preview-state> <print-preview-model id="model" settings="{{settings}}" destination="{{destination_}}" document-info="{{documentInfo_}}" recent-destinations="{{recentDestinations_}}" on-save-sticky-settings="onSaveStickySettings_"> </print-preview-model> - <div id="sidebar"> - <print-preview-header destination="[[destination_]]" state="{{state_}}" - settings="[[settings]]"></print-preview-header> + <div id="sidebar" on-setting-valid-changed="onSettingValidChanged_"> + <print-preview-header destination="[[destination_]]" state="[[state]]" + error-message="[[errorMessage_]]" settings="[[settings]]" + on-print-requested="onPrintRequested_" + on-cancel-requested="onCancelRequested_"> + </print-preview-header> <div id="settings-sections"> - <print-preview-destination-settings destination="[[destination_]]"> + <print-preview-destination-settings id="destinationSettings" + destination="[[destination_]]" + destination-store="[[destinationStore_]]" + disabled="[[controlsDisabled_]]" state="[[state]]" + recent-destinations="[[recentDestinations_]]" + user-info="{{userInfo_}}"> </print-preview-destination-settings> <print-preview-pages-settings settings="{{settings}}" - document-info="[[documentInfo_]]" + document-info="[[documentInfo_]]" disabled="[[controlsDisabled_]]" hidden$="[[!settings.pages.available]]"> </print-preview-pages-settings> <print-preview-copies-settings settings="{{settings}}" + disabled="[[controlsDisabled_]]" hidden$="[[!settings.copies.available]]"> </print-preview-copies-settings> <print-preview-layout-settings settings="{{settings}}" + disabled="[[controlsDisabled_]]" hidden$="[[!settings.layout.available]]"> </print-preview-layout-settings> <print-preview-color-settings settings="{{settings}}" + disabled="[[controlsDisabled_]]" hidden$="[[!settings.color.available]]"> </print-preview-color-settings> <print-preview-media-size-settings settings="{{settings}}" capability="[[destination_.capabilities.printer.media_size]]" + disabled="[[controlsDisabled_]]" hidden$="[[!settings.mediaSize.available]]"> </print-preview-media-size-settings> <print-preview-margins-settings settings="{{settings}}" + disabled="[[controlsDisabled_]]" hidden$="[[!settings.margins.available]]"> </print-preview-margins-settings> <print-preview-dpi-settings settings="{{settings}}" capability="[[destination_.capabilities.printer.dpi]]" + disabled="[[controlsDisabled_]]" hidden$="[[!settings.dpi.available]]"> </print-preview-dpi-settings> <print-preview-scaling-settings settings="{{settings}}" - document-info="[[documentInfo_]]" + document-info="[[documentInfo_]]" disabled="[[controlsDisabled_]]" hidden$="[[!settings.scaling.available]]"> </print-preview-scaling-settings> <print-preview-other-options-settings settings="{{settings}}" + disabled="[[controlsDisabled_]]" hidden$="[[!settings.otherOptions.available]]"> </print-preview-other-options-settings> <print-preview-advanced-options-settings settings="{{settings}}" + destination="[[destination_]]" disabled="[[controlsDisabled_]]" hidden$="[[!settings.vendorItems.available]]"> </print-preview-advanced-options-settings> </div> </div> - <div id="preview-area"> - <print-preview-preview-area settings="{{settings}}" + <div id="preview-area-container"> + <print-preview-preview-area id="previewArea" settings="{{settings}}" destination="[[destination_]]" document-info="{{documentInfo_}}" - state="{{state_}}"> + state="[[state]]" on-preview-failed="onPreviewFailed_" + on-preview-loaded="onPreviewLoaded_"> </print-preview-preview-area> </div> </template> diff --git a/chromium/chrome/browser/resources/print_preview/new/app.js b/chromium/chrome/browser/resources/print_preview/new/app.js index c45886a2ec6..0e4dcebab1d 100644 --- a/chromium/chrome/browser/resources/print_preview/new/app.js +++ b/chromium/chrome/browser/resources/print_preview/new/app.js @@ -17,14 +17,21 @@ Polymer({ notify: true, }, - /** @private {print_preview.DocumentInfo} */ - documentInfo_: { + /** @private {print_preview.Destination} */ + destination_: { type: Object, notify: true, }, - /** @private {print_preview.Destination} */ - destination_: { + /** @private {?print_preview.DestinationStore} */ + destinationStore_: { + type: Object, + notify: true, + value: null, + }, + + /** @private {print_preview.DocumentInfo} */ + documentInfo_: { type: Object, notify: true, }, @@ -35,40 +42,55 @@ Polymer({ notify: true, }, - /** @private {!print_preview_new.State} */ - state_: { + /** @type {!print_preview_new.State} */ + state: { + type: Number, + observer: 'onStateChanged_', + }, + + /** @private {?print_preview.UserInfo} */ + userInfo_: { type: Object, notify: true, - value: { - previewLoading: false, - previewFailed: false, - cloudPrintError: '', - privetExtensionError: '', - invalidSettings: false, - initialized: false, - cancelled: false, - }, + value: null, }, - }, - /** @private {?print_preview.NativeLayer} */ - nativeLayer_: null, + /** @private {string} */ + errorMessage_: { + type: String, + notify: true, + value: '', + }, - /** @private {?print_preview.UserInfo} */ - userInfo_: null, + /** @private {boolean} */ + controlsDisabled_: { + type: Boolean, + notify: true, + computed: 'computeControlsDisabled_(state)', + } + }, /** @private {?WebUIListenerTracker} */ listenerTracker_: null, - /** @private {?print_preview.DestinationStore} */ - destinationStore_: null, + /** @type {!print_preview.MeasurementSystem} */ + measurementSystem_: new print_preview.MeasurementSystem( + ',', '.', print_preview.MeasurementSystemUnitType.IMPERIAL), + + /** @private {?print_preview.NativeLayer} */ + nativeLayer_: null, + + /** @private {?cloudprint.CloudPrintInterface} */ + cloudPrintInterface_: null, /** @private {!EventTracker} */ tracker_: new EventTracker(), - /** @type {!print_preview.MeasurementSystem} */ - measurementSystem_: new print_preview.MeasurementSystem( - ',', '.', print_preview.MeasurementSystemUnitType.IMPERIAL), + /** @private {boolean} */ + cancelled_: false, + + /** @private {boolean} */ + isInAppKioskMode_: false, /** @override */ attached: function() { @@ -76,6 +98,8 @@ Polymer({ this.documentInfo_ = new print_preview.DocumentInfo(); this.userInfo_ = new print_preview.UserInfo(); this.listenerTracker_ = new WebUIListenerTracker(); + this.listenerTracker_.add( + 'use-cloud-print', this.onCloudPrintEnable_.bind(this)); this.destinationStore_ = new print_preview.DestinationStore( this.userInfo_, this.listenerTracker_); this.tracker_.add( @@ -98,6 +122,14 @@ Polymer({ }, /** + * @return {boolean} Whether the controls should be disabled. + * @private + */ + computeControlsDisabled_: function() { + return this.state != print_preview_new.State.READY; + }, + + /** * @param {!print_preview.NativeInitialSettings} settings * @private */ @@ -109,7 +141,7 @@ Polymer({ this.notifyPath('documentInfo_.hasSelection'); this.notifyPath('documentInfo_.title'); this.notifyPath('documentInfo_.pageCount'); - this.$.model.updateFromStickySettings(settings.serializedAppStateStr); + this.$.model.setStickySettings(settings.serializedAppStateStr); this.measurementSystem_.setSystem( settings.thousandsDelimeter, settings.decimalDelimeter, settings.unitType); @@ -120,9 +152,42 @@ Polymer({ this.recentDestinations_); }, + /** + * Called when Google Cloud Print integration is enabled by the + * PrintPreviewHandler. + * Fetches the user's cloud printers. + * @param {string} cloudPrintUrl The URL to use for cloud print servers. + * @param {boolean} appKioskMode Whether to print automatically for kiosk + * mode. + * @private + */ + onCloudPrintEnable_: function(cloudPrintUrl, appKioskMode) { + assert(!this.cloudPrintInterface_); + this.cloudPrintInterface_ = new cloudprint.CloudPrintInterface( + cloudPrintUrl, assert(this.nativeLayer_), assert(this.userInfo_), + appKioskMode); + this.tracker_.add( + assert(this.cloudPrintInterface_), + cloudprint.CloudPrintInterfaceEventType.SUBMIT_DONE, + this.close_.bind(this)); + [cloudprint.CloudPrintInterfaceEventType.SEARCH_FAILED, + cloudprint.CloudPrintInterfaceEventType.SUBMIT_FAILED, + cloudprint.CloudPrintInterfaceEventType.PRINTER_FAILED, + ].forEach(eventType => { + this.tracker_.add( + assert(this.cloudPrintInterface_), eventType, + this.onCloudPrintError_.bind(this)); + }); + + this.destinationStore_.setCloudPrintInterface(this.cloudPrintInterface_); + if (this.$.destinationSettings.isDialogOpen()) + this.destinationStore_.startLoadCloudDestinations(); + }, + /** @private */ onDestinationSelect_: function() { this.destination_ = this.destinationStore_.selectedDestination; + this.$.state.transitTo(print_preview_new.State.NOT_READY); }, /** @private */ @@ -130,16 +195,10 @@ Polymer({ this.set( 'destination_.capabilities', this.destinationStore_.selectedDestination.capabilities); - if (!this.state_.initialized) - this.set('state_.initialized', true); - }, - - /** @private */ - onPreviewCancelled_: function() { - if (!this.state_.cancelled) - return; - this.detached(); - this.nativeLayer_.dialogClose(true); + if (this.state != print_preview_new.State.READY) + this.$.state.transitTo(print_preview_new.State.READY); + if (!this.$.model.initialized()) + this.$.model.applyStickySettings(); }, /** @@ -149,4 +208,128 @@ Polymer({ onSaveStickySettings_: function(e) { this.nativeLayer_.saveAppState(/** @type {string} */ (e.detail)); }, + + /** @private */ + onStateChanged_: function() { + if (this.state == print_preview_new.State.CLOSING) { + this.remove(); + this.nativeLayer_.dialogClose(this.cancelled_); + } else if (this.state == print_preview_new.State.HIDDEN) { + this.nativeLayer_.hidePreview(); + } else if (this.state == print_preview_new.State.PRINTING) { + const destination = assert(this.destinationStore_.selectedDestination); + const whenPrintDone = + this.nativeLayer_.print(this.$.model.createPrintTicket(destination)); + if (destination.isLocal) { + const onError = destination.id == + print_preview.Destination.GooglePromotedId.SAVE_AS_PDF ? + this.onFileSelectionCancel_.bind(this) : + this.onPrintFailed_.bind(this); + whenPrintDone.then(this.close_.bind(this), onError); + } else { + // Cloud print resolves when print data is returned to submit to cloud + // print, or if print ticket cannot be read, no PDF data is found, or + // PDF is oversized. + whenPrintDone.then( + this.onPrintToCloud_.bind(this), this.onPrintFailed_.bind(this)); + } + } + }, + + /** @private */ + onPreviewLoaded_: function() { + if (this.state == print_preview_new.State.HIDDEN) + this.$.state.transitTo(print_preview_new.State.PRINTING); + }, + + /** @private */ + onPrintRequested_: function() { + this.$.state.transitTo( + this.$.previewArea.previewLoaded() ? print_preview_new.State.PRINTING : + print_preview_new.State.HIDDEN); + }, + + /** @private */ + onCancelRequested_: function() { + this.cancelled_ = true; + this.$.state.transitTo(print_preview_new.State.CLOSING); + }, + + /** + * @param {!CustomEvent} e The event containing the new validity. + * @private + */ + onSettingValidChanged_: function(e) { + this.$.state.transitTo( + /** @type {boolean} */ (e.detail) ? + print_preview_new.State.READY : + print_preview_new.State.INVALID_TICKET); + }, + + /** @private */ + onFileSelectionCancel_: function() { + this.$.state.transitTo(print_preview_new.State.READY); + }, + + /** + * Called when the native layer has retrieved the data to print to Google + * Cloud Print. + * @param {string} data The body to send in the HTTP request. + * @private + */ + onPrintToCloud_: function(data) { + assert( + this.cloudPrintInterface_ != null, 'Google Cloud Print is not enabled'); + const destination = assert(this.destinationStore_.selectedDestination); + this.cloudPrintInterface_.submit( + destination, this.$.model.createCloudJobTicket(destination), + assert(this.documentInfo_), data); + }, + + /** + * Called when printing to a privet, cloud, or extension printer fails. + * @param {*} httpError The HTTP error code, or -1 or a string describing + * the error, if not an HTTP error. + * @private + */ + onPrintFailed_: function(httpError) { + console.error('Printing failed with error code ' + httpError); + this.errorMessage_ = httpError.toString(); + this.$.state.transitTo(print_preview_new.State.FATAL_ERROR); + }, + + /** @private */ + onPreviewFailed_: function() { + this.$.state.transitTo(print_preview_new.State.FATAL_ERROR); + }, + + /** + * Called when there was an error communicating with Google Cloud print. + * Displays an error message in the print header. + * @param {!Event} event Contains the error message. + * @private + */ + onCloudPrintError_: function(event) { + if (event.status == 0) { + return; // Ignore, the system does not have internet connectivity. + } + if (event.status == 403) { + if (!this.isInAppKioskMode_) { + this.$.destinationSettings.showCloudPrintPromo(); + } + } else { + this.set('state_.cloudPrintError', event.message); + } + if (event.status == 200) { + console.error( + `Google Cloud Print Error: (${event.errorCode}) ${event.message}`); + } else { + console.error(`Google Cloud Print Error: HTTP status ${event.status}`); + } + }, + + /** @private */ + close_: function() { + this.$.state.transitTo(print_preview_new.State.CLOSING); + }, }); diff --git a/chromium/chrome/browser/resources/print_preview/new/button_css.html b/chromium/chrome/browser/resources/print_preview/new/button_css.html index d33555445a4..77481fa255a 100644 --- a/chromium/chrome/browser/resources/print_preview/new/button_css.html +++ b/chromium/chrome/browser/resources/print_preview/new/button_css.html @@ -13,13 +13,13 @@ <if expr="not is_ios"> button:enabled:hover { background-image: linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0); - @apply(--print-preview-hover); + @apply --print-preview-hover; } </if> button:enabled:active { background-image: linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7); - @apply(--print-preview-active); + @apply --print-preview-active; } </style> </template> diff --git a/chromium/chrome/browser/resources/print_preview/new/checkbox_radio_css.html b/chromium/chrome/browser/resources/print_preview/new/checkbox_radio_css.html index f2564079cbd..159ad3c3dbb 100644 --- a/chromium/chrome/browser/resources/print_preview/new/checkbox_radio_css.html +++ b/chromium/chrome/browser/resources/print_preview/new/checkbox_radio_css.html @@ -48,14 +48,14 @@ [type='radio']) { background-image: linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0); - @apply(--print-preview-hover); + @apply --print-preview-hover; } </if> input:enabled:active:-webkit-any([type='checkbox'], [type='radio']) { background-image: linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7); - @apply(--print-preview-active); + @apply --print-preview-active; } input:disabled:-webkit-any([type='checkbox'], diff --git a/chromium/chrome/browser/resources/print_preview/new/color_settings.html b/chromium/chrome/browser/resources/print_preview/new/color_settings.html index b95459a0f05..88adf84c90c 100644 --- a/chromium/chrome/browser/resources/print_preview/new/color_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/color_settings.html @@ -11,7 +11,8 @@ <print-preview-settings-section> <span id="color-label" slot="title">$i18n{optionColor}</span> <div slot="controls"> - <select aria-labelledby="color-label" on-change="onChange_"> + <select aria-labelledby="color-label" on-change="onChange_" + disabled$="[[disabled]]"> <option value="bw" selected>$i18n{optionBw}</option> <option value="color">$i18n{optionColor}</option> </select> diff --git a/chromium/chrome/browser/resources/print_preview/new/color_settings.js b/chromium/chrome/browser/resources/print_preview/new/color_settings.js index 075141f2a01..4c8631aa5ec 100644 --- a/chromium/chrome/browser/resources/print_preview/new/color_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/color_settings.js @@ -7,6 +7,10 @@ Polymer({ behaviors: [SettingsBehavior], + properties: { + disabled: Boolean, + }, + observers: ['onColorSettingChange_(settings.color.value)'], /** diff --git a/chromium/chrome/browser/resources/print_preview/new/compiled_resources2.gyp b/chromium/chrome/browser/resources/print_preview/new/compiled_resources2.gyp index 0441a3c1e62..e75ada55312 100644 --- a/chromium/chrome/browser/resources/print_preview/new/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/print_preview/new/compiled_resources2.gyp @@ -21,6 +21,7 @@ 'preview_area', 'model', 'state', + '../compiled_resources2.gyp:cloud_print_interface', '../compiled_resources2.gyp:native_layer', '../data/compiled_resources2.gyp:destination', '../data/compiled_resources2.gyp:destination_store', @@ -39,6 +40,7 @@ 'model', 'settings_behavior', 'state', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:load_time_data', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], @@ -47,6 +49,11 @@ 'target_name': 'destination_settings', 'dependencies': [ '../data/compiled_resources2.gyp:destination', + '../data/compiled_resources2.gyp:destination_store', + '../data/compiled_resources2.gyp:user_info', + 'destination_dialog', + 'state', + '<(DEPTH)/ui/webui/resources/cr_elements/cr_lazy_render/compiled_resources2.gyp:cr_lazy_render', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, @@ -55,7 +62,6 @@ 'dependencies': [ 'settings_behavior', '../data/compiled_resources2.gyp:document_info', - '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:load_time_data', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], @@ -126,6 +132,9 @@ { 'target_name': 'advanced_options_settings', 'dependencies': [ + '../data/compiled_resources2.gyp:destination', + 'advanced_settings_dialog', + 'settings_behavior', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, @@ -156,6 +165,7 @@ 'target_name': 'preview_area', 'dependencies': [ '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:web_ui_listener_behavior', '../../pdf/compiled_resources2.gyp:pdf_scripting_api', '../compiled_resources2.gyp:native_layer', @@ -166,12 +176,80 @@ '../data/compiled_resources2.gyp:size', '../data/compiled_resources2.gyp:margins', '../data/compiled_resources2.gyp:printable_area', + 'model', 'settings_behavior', 'state', ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, { + 'target_name': 'destination_dialog', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/cr_elements/cr_dialog/compiled_resources2.gyp:cr_dialog', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:load_time_data', + '../data/compiled_resources2.gyp:destination', + '../data/compiled_resources2.gyp:destination_store', + '../data/compiled_resources2.gyp:user_info', + 'destination_list', + 'print_preview_search_box', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'destination_list', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', + '../compiled_resources2.gyp:native_layer', + '../data/compiled_resources2.gyp:destination', + 'destination_list_item', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'destination_list_item', + 'dependencies': [ + 'highlight_utils', + '../data/compiled_resources2.gyp:destination', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'advanced_settings_dialog', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/cr_elements/cr_dialog/compiled_resources2.gyp:cr_dialog', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', + '../data/compiled_resources2.gyp:destination', + 'advanced_settings_item', + 'print_preview_search_box', + 'settings_behavior', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'advanced_settings_item', + 'dependencies': [ + 'highlight_utils', + '../compiled_resources2.gyp:print_preview_utils', + '../data/compiled_resources2.gyp:destination', + 'settings_behavior', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'print_preview_search_box', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/cr_elements/cr_search_field/compiled_resources2.gyp:cr_search_field_behavior', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'highlight_utils', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:search_highlight_utils', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { 'target_name': 'model', 'dependencies': [ 'settings_behavior', diff --git a/chromium/chrome/browser/resources/print_preview/new/copies_settings.html b/chromium/chrome/browser/resources/print_preview/new/copies_settings.html index f1168f6f5d8..f64b68121a0 100644 --- a/chromium/chrome/browser/resources/print_preview/new/copies_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/copies_settings.html @@ -11,12 +11,13 @@ </style> <print-preview-number-settings-section max-value="999" min-value=1 default-value="1" input-label="$i18n{copiesLabel}" - input-string="{{inputString_}}" input-valid="{{inputValid_}}" - hint-message="$i18n{copiesInstruction}"> + disabled="[[disabled]]" current-value="{{currentValue_}}" + input-valid="{{inputValid_}}" hint-message="$i18n{copiesInstruction}"> <div slot="opt-inside-content" class="checkbox" aria-live="polite" - hidden$="[[collateHidden_(inputString_, inputValid_)]]"> + hidden$="[[collateHidden_(currentValue_, inputValid_)]]"> <label> <input id="collate" type="checkbox" on-change="onCollateChange_" + disabled$="[[getDisabled(state)]]" aria-labelledby="copies-collate-label"> <span id="copies-collate-label">$i18n{optionCollate}</span> </label> diff --git a/chromium/chrome/browser/resources/print_preview/new/copies_settings.js b/chromium/chrome/browser/resources/print_preview/new/copies_settings.js index 826c5de2217..1115ac4c99f 100644 --- a/chromium/chrome/browser/resources/print_preview/new/copies_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/copies_settings.js @@ -9,17 +9,19 @@ Polymer({ properties: { /** @private {string} */ - inputString_: String, + currentValue_: String, /** @private {boolean} */ inputValid_: Boolean, + + disabled: Boolean, }, /** @private {boolean} */ isInitialized_: false, observers: [ - 'onInputChanged_(inputString_, inputValid_)', + 'onInputChanged_(currentValue_, inputValid_)', 'onInitialized_(settings.copies.value, settings.collate.value)' ], @@ -32,7 +34,7 @@ Polymer({ return; this.isInitialized_ = true; const copies = this.getSetting('copies'); - this.inputString_ = /** @type {string} */ (copies.value.toString()); + this.currentValue_ = /** @type {string} */ (copies.value.toString()); const collate = this.getSetting('collate'); this.$.collate.checked = /** @type {boolean} */ (collate.value); }, @@ -44,7 +46,7 @@ Polymer({ */ onInputChanged_: function() { this.setSetting( - 'copies', this.inputValid_ ? parseInt(this.inputString_, 10) : 1); + 'copies', this.inputValid_ ? parseInt(this.currentValue_, 10) : 1); this.setSettingValid('copies', this.inputValid_); }, @@ -53,7 +55,7 @@ Polymer({ * @private */ collateHidden_: function() { - return !this.inputValid_ || parseInt(this.inputString_, 10) == 1; + return !this.inputValid_ || parseInt(this.currentValue_, 10) == 1; }, /** @private */ diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_dialog.html b/chromium/chrome/browser/resources/print_preview/new/destination_dialog.html new file mode 100644 index 00000000000..540227ae57d --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/destination_dialog.html @@ -0,0 +1,132 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/cr_dialog/cr_dialog.html"> +<link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> +<link rel="import" href="chrome://resources/html/action_link_css.html"> +<link rel="import" href="chrome://resources/html/i18n_behavior.html"> +<link rel="import" href="chrome://resources/html/load_time_data.html"> +<link rel="import" href="../data/destination.html"> +<link rel="import" href="../data/destination_store.html"> +<link rel="import" href="button_css.html"> +<link rel="import" href="destination_list.html"> +<link rel="import" href="print_preview_search_box.html"> +<link rel="import" href="print_preview_shared_css.html"> +<link rel="import" href="search_dialog_css.html"> +<link rel="import" href="select_css.html"> + +<dom-module id="print-preview-destination-dialog"> + <template> + <style include="print-preview-shared button action-link select cr-hidden-style search-dialog"> + :host #dialog { + width: 640px; + } + + :host .user-info { + font-size: calc(13/15 * 1em); + margin-top: 14px; + } + + :host .user-info .account-select-label { + -webkit-padding-end: 18px; + } + + :host .user-info .account-select { + width: auto + } + + :host #dialog .cloudprint-promo { + align-items: center; + background-color: #f5f5f5; + border-color: #e7e7e7; + border-top-style: solid; + border-width: 1px; + color: #888; + display: flex; + padding: 14px 17px; + } + + :host .cloudprint-promo .promo-text { + flex: 1; + } + + :host .cloudprint-promo .icon { + -webkit-margin-end: 12px; + display: block; + height: 24px; + width: 24px; + } + + :host .cloudprint-promo .close-button { + -webkit-margin-start: 12px; + background-image: -webkit-image-set( + url(chrome://theme/IDR_CLOSE_DIALOG) 1x, + url(chrome://theme/IDR_CLOSE_DIALOG@2x) 2x); + background-repeat: no-repeat; + background-size: 14px; + height: 14px; + width: 14px; + } + + :host .cloudprint-promo .close-button:hover { + background-image: -webkit-image-set( + url(chrome://theme/IDR_CLOSE_DIALOG_H) 1x, + url(chrome://theme/IDR_CLOSE_DIALOG_H@2x) 2x); + } + + :host .cloudprint-promo .close-button:active { + background-image: -webkit-image-set( + url(chrome://theme/IDR_CLOSE_DIALOG_P) 1x, + url(chrome://theme/IDR_CLOSE_DIALOG_P@2x) 2x); + } + </style> + <dialog is="cr-dialog" id="dialog" on-close="onCloseOrCancel_"> + <div slot="title"> + <div>$i18n{destinationSearchTitle}</div> + <div class="user-info" hidden$="[[!userInfo.loggedIn]]"> + <label class="account-select-label" id="accountSelectLabel"> + $i18n{accountSelectTitle} + </label> + <select class="account-select" aria-labelledby="accountSelectLabel" + on-change="onUserChange_"> + <template is="dom-repeat" items="[[userInfo.users]]"> + <option selected="[[isSelected_(item, userInfo.activeUser)]]" + value="[[item]]"> + [[item]] + </option> + </template> + <option value="">$i18n{addAccountTitle}</option> + </select> + </div> + <print-preview-search-box id="searchBox" + label="$i18n{searchBoxPlaceholder}" search-query="{{searchQuery_}}"> + </print-preview-search-box> + </div> + <div slot="body" scrollable> + <print-preview-destination-list + destinations="[[recentDestinationList_]]" + search-query="[[searchQuery_]]" + title="$i18n{recentDestinationsTitle}" + on-destination-selected="onDestinationSelected_"> + </print-preview-destination-list> + <print-preview-destination-list destinations="[[destinations_]]" + has-action-link loading-destinations="[[loadingDestinations_]]" + search-query="[[searchQuery_]]" + title="$i18n{printDestinationsTitle}" + on-destination-selected="onDestinationSelected_"> + </print-preview-destination-list> + </div> + <div slot="button-container"> + <button class="cancel-button" on-click="onCancelButtonClick_"> + $i18n{cancel} + </button> + </div> + <div class="cloudprint-promo" slot="footer" + hidden$="[[!showCloudPrintPromo]]"> + <img src="../images/cloud.png" class="icon" alt=""> + <div class="promo-text"></div> + <div class="close-button"></div> + </div> + </dialog> + </template> + <script src="destination_dialog.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_dialog.js b/chromium/chrome/browser/resources/print_preview/new/destination_dialog.js new file mode 100644 index 00000000000..7e2162941c6 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/destination_dialog.js @@ -0,0 +1,205 @@ +// Copyright 2018 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. + +Polymer({ + is: 'print-preview-destination-dialog', + + behaviors: [I18nBehavior], + + properties: { + /** @type {?print_preview.DestinationStore} */ + destinationStore: { + type: Object, + observer: 'onDestinationStoreSet_', + }, + + /** @type {!print_preview.UserInfo} */ + userInfo: { + type: Object, + notify: true, + }, + + /** @type {boolean} */ + showCloudPrintPromo: { + type: Boolean, + notify: true, + }, + + /** @private {!Array<!print_preview.Destination>} */ + destinations_: { + type: Array, + notify: true, + value: [], + }, + + /** @private {boolean} */ + loadingDestinations_: { + type: Boolean, + value: false, + }, + + /** @type {!Array<!print_preview.RecentDestination>} */ + recentDestinations: Array, + + /** @private {!Array<!print_preview.Destination>} */ + recentDestinationList_: { + type: Array, + notify: true, + computed: 'computeRecentDestinationList_(' + + 'destinationStore, recentDestinations, recentDestinations.*, ' + + 'userInfo, destinations_.*)', + }, + + /** @private {?RegExp} */ + searchQuery_: { + type: Object, + value: null, + }, + }, + + /** @private {!EventTracker} */ + tracker_: new EventTracker(), + + /** @override */ + ready: function() { + this.$$('.promo-text').innerHTML = + this.i18nAdvanced('cloudPrintPromotion', { + substitutions: ['<a is="action-link" class="sign-in">', '</a>'], + attrs: { + 'is': (node, v) => v == 'action-link', + 'class': (node, v) => v == 'sign-in', + }, + }); + }, + + /** @override */ + attached: function() { + this.tracker_.add( + assert(this.$$('.sign-in')), 'click', this.onSignInClick_.bind(this)); + this.tracker_.add( + assert(this.$$('.cloudprint-promo > .close-button')), 'click', + this.onCloudPrintPromoDismissed_.bind(this)); + }, + + /** @private */ + onDestinationStoreSet_: function() { + assert(this.destinations_.length == 0); + const destinationStore = assert(this.destinationStore); + this.tracker_.add( + destinationStore, + print_preview.DestinationStore.EventType.DESTINATIONS_INSERTED, + this.updateDestinations_.bind(this)); + this.tracker_.add( + destinationStore, + print_preview.DestinationStore.EventType.DESTINATION_SEARCH_DONE, + this.updateDestinations_.bind(this)); + }, + + /** @private */ + updateDestinations_: function() { + this.notifyPath('userInfo.users'); + this.notifyPath('userInfo.activeUser'); + this.notifyPath('userInfo.loggedIn'); + if (this.userInfo.loggedIn) + this.showCloudPrintPromo = false; + + this.destinations_ = this.userInfo ? + this.destinationStore.destinations(this.userInfo.activeUser) : + []; + this.loadingDestinations_ = + this.destinationStore.isPrintDestinationSearchInProgress; + }, + + /** + * @return {!Array<!print_preview.Destination>} + * @private + */ + computeRecentDestinationList_: function() { + let recentDestinations = []; + const filterAccount = this.userInfo.activeUser; + this.recentDestinations.forEach((recentDestination) => { + const destination = this.destinationStore.getDestination( + recentDestination.origin, recentDestination.id, + recentDestination.account || ''); + if (destination && + (!destination.account || destination.account == filterAccount)) { + recentDestinations.push(destination); + } + }); + return recentDestinations; + }, + + /** @private */ + onCloseOrCancel_: function() { + if (this.searchQuery_) + this.$.searchBox.setValue(''); + }, + + /** @private */ + onCancelButtonClick_: function() { + this.$.dialog.cancel(); + }, + + /** + * @param {!CustomEvent} e Event containing the selected destination. + * @private + */ + onDestinationSelected_: function(e) { + this.destinationStore.selectDestination( + /** @type {!print_preview.Destination} */ (e.detail)); + this.$.dialog.close(); + }, + + show: function() { + this.loadingDestinations_ = + this.destinationStore.isPrintDestinationSearchInProgress; + this.$.dialog.showModal(); + }, + + /** @return {boolean} Whether the dialog is open. */ + isOpen: function() { + return this.$.dialog.hasAttribute('open'); + }, + + /** @private */ + isSelected_: function(account) { + return account == this.userInfo.activeUser; + }, + + /** @private */ + onSignInClick_: function() { + print_preview.NativeLayer.getInstance().signIn(false).then(() => { + this.destinationStore.onDestinationsReload(); + }); + }, + + /** @private */ + onCloudPrintPromoDismissed_: function() { + this.showCloudPrintPromo = false; + }, + + /** @private */ + onUserChange_: function() { + const select = this.$$('select'); + const account = select.value; + if (account) { + this.showCloudPrintPromo = false; + this.userInfo.activeUser = account; + this.notifyPath('userInfo.activeUser'); + this.notifyPath('userInfo.loggedIn'); + this.destinationStore.reloadUserCookieBasedDestinations(); + } else { + print_preview.NativeLayer.getInstance().signIn(true).then( + this.destinationStore.onDestinationsReload.bind( + this.destinationStore)); + const options = select.querySelectorAll('option'); + for (let i = 0; i < options.length; i++) { + if (options[i].value == this.userInfo.activeUser) { + select.selectedIndex = i; + break; + } + } + } + }, +}); diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_list.html b/chromium/chrome/browser/resources/print_preview/new/destination_list.html new file mode 100644 index 00000000000..3a008a5df4d --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/destination_list.html @@ -0,0 +1,102 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> +<link rel="import" href="chrome://resources/html/action_link_css.html"> +<link rel="import" href="chrome://resources/html/i18n_behavior.html"> +<link rel="import" href="../native_layer.html"> +<link rel="import" href="../data/destination.html"> +<link rel="import" href="destination_list_item.html"> +<link rel="import" href="print_preview_shared_css.html"> +<link rel="import" href="throbber_css.html"> + +<dom-module id="print-preview-destination-list"> + <template> + <style include="print-preview-shared action-link cr-hidden-style throbber"> + :host { + padding: 0 14px 18px; + user-select: none; + } + + :host > header { + -webkit-padding-end: 19px; + -webkit-padding-start: 0; + background-color: transparent; + border-bottom: 1px solid #d2d2d2; + padding-bottom: 8px; + } + + :host :-webkit-any(.title, .action-link, .total) { + -webkit-padding-end: 8px; + -webkit-padding-start: 4px; + display: inline; + vertical-align: middle; + } + + :host .throbber-container { + -webkit-padding-end: 16px; + -webkit-padding-start: 8px; + display: inline-block; + position: relative; + vertical-align: middle; + } + + :host .throbber { + vertical-align: middle; + } + + :host .no-destinations-message { + -webkit-padding-start: 18px; + color: #999; + padding-bottom: 8px; + padding-top: 8px; + } + + :host .list-item { + -webkit-padding-end: 2px; + -webkit-padding-start: 18px; + cursor: default; + display: flex; + padding-bottom: 3px; + padding-top: 3px; + } + + :not(.moving).list-item { + transition: background-color 150ms; + } + + .list-item:hover, + .list-item:focus { + background-color: rgb(228, 236, 247); + } + + .list-item:focus { + outline: none; + } + </style> + <header> + <h4 class="title">[[title]]</h4> + <span class="total" hidden$="[[!showDestinationsTotal_]]"> + [[i18n('destinationCount', matchingDestinationsCount_)]] + </span> + <a is="action-link" class="action-link" hidden$="[[!hasActionLink]]" + on-click="onActionLinkClick_"> + $i18n{manage} + </a> + <div class="throbber-container" hidden$="[[!loadingDestinations]]"> + <div class="throbber"></div> + </div> + </header> + <template is="dom-repeat" items="[[destinations]]" notify-dom-change + on-dom-change="updateIfNeeded_"> + <print-preview-destination-list-item class="list-item" + search-query="[[searchQuery]]" destination="[[item]]" + on-click="onDestinationSelected_"> + </print-preview-destination-list-item> + </template> + <div class="no-destinations-message" hidden$="[[hasDestinations_]]"> + $i18n{noDestinationsMessage} + </div> + </template> + <script src="destination_list.js"></script> +</dom-module> + diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_list.js b/chromium/chrome/browser/resources/print_preview/new/destination_list.js new file mode 100644 index 00000000000..b83f9309149 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/destination_list.js @@ -0,0 +1,138 @@ +// Copyright 2018 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. + +(function() { +'use strict'; + +Polymer({ + is: 'print-preview-destination-list', + + behaviors: [I18nBehavior], + + properties: { + /** @type {Array<!print_preview.Destination>} */ + destinations: { + type: Array, + observer: 'destinationsChanged_', + }, + + /** @type {boolean} */ + hasActionLink: { + type: Boolean, + value: false, + }, + + /** @type {boolean} */ + loadingDestinations: { + type: Boolean, + value: false, + }, + + /** @type {?RegExp} */ + searchQuery: { + type: Object, + observer: 'update_', + }, + + /** @type {boolean} */ + title: String, + + /** @private {number} */ + matchingDestinationsCount_: { + type: Number, + value: 0, + }, + + /** @private {boolean} */ + hasDestinations_: { + type: Boolean, + computed: 'computeHasDestinations_(matchingDestinationsCount_)', + }, + + /** @private {boolean} */ + showDestinationsTotal_: { + type: Boolean, + computed: 'computeShowDestinationsTotal_(matchingDestinationsCount_)', + }, + }, + + /** @private {boolean} */ + newDestinations_: false, + + /** + * @param {!Array<!print_preview.Destination>} current + * @param {?Array<!print_preview.Destination>} previous + * @private + */ + destinationsChanged_: function(current, previous) { + if (previous == undefined) { + this.matchingDestinationsCount_ = this.destinations.length; + } else { + this.newDestinations_ = true; + } + }, + + /** @private */ + updateIfNeeded_: function() { + if (!this.newDestinations_) + return; + this.newDestinations_ = false; + this.update_(); + }, + + /** @private */ + update_: function() { + if (!this.destinations) + return; + + const listItems = + this.shadowRoot.querySelectorAll('print-preview-destination-list-item'); + + let matchCount = 0; + listItems.forEach(item => { + item.hidden = + !!this.searchQuery && !item.destination.matches(this.searchQuery); + if (!item.hidden) { + matchCount++; + item.update(); + } + }); + + this.matchingDestinationsCount_ = + !this.searchQuery ? listItems.length : matchCount; + }, + + /** + * @return {boolean} + * @private + */ + computeHasDestinations_: function() { + return !this.destinations || this.matchingDestinationsCount_ > 0; + }, + + /** + * @return {boolean} + * @private + */ + computeShowDestinationsTotal_: function() { + return this.matchingDestinationsCount_ > 4; + }, + + /** @private */ + onActionLinkClick_: function() { + print_preview.NativeLayer.getInstance().managePrinters(); + }, + + /** + * @param {!Event} e Event containing the destination that was selected. + * @private + */ + onDestinationSelected_: function(e) { + this.fire( + 'destination-selected', + /** @type {PrintPreviewDestinationListItemElement} */ + (e.target).destination); + }, +}); +})(); diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_list_item.html b/chromium/chrome/browser/resources/print_preview/new/destination_list_item.html new file mode 100644 index 00000000000..ad4503b6c68 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/destination_list_item.html @@ -0,0 +1,136 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> +<link rel="import" href="../native_layer.html"> +<link rel="import" href="../data/destination.html"> +<link rel="import" href="highlight_utils.html"> +<link rel="import" href="print_preview_shared_css.html"> + +<dom-module id="print-preview-destination-list-item"> + <template> + <style include="print-preview-shared action-link cr-hidden-style"> + :host .icon { + -webkit-margin-end: 8px; + display: inline-block; + flex: 0 0 auto; + height: 24px; + transition: opacity 150ms; + vertical-align: middle; + width: 24px; + } + + :host .name, + :host .search-hint { + flex: 0 1 auto; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + white-space: nowrap; + } + + :host .search-hint { + -webkit-margin-start: 1em; + color: #999; + font-size: 75%; + } + + :host .connection-status, + :host .learn-more-link { + -webkit-margin-start: 1em; + flex: 0 0 auto; + font-size: 75%; + line-height: 24px; + vertical-align: middle; + } + + :host .learn-more-link { + color: rgb(51, 103, 214); + } + + :host .register-promo { + -webkit-margin-start: 1em; + flex: 0 0 auto; + } + + :host .extension-controlled-indicator { + display: flex; + flex: 1; + justify-content: flex-end; + min-width: 150px; + } + + :host .extension-name { + -webkit-margin-start: 1em; + color: #777; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + :host .extension-icon { + background-position: center; + background-repeat: no-repeat; + cursor: pointer; + flex: 0 0 auto; + height: 24px; + margin: 0 3px; + width: 24px; + } + + :host .configuring-in-progress-text, + :host .configuring-failed-text { + -webkit-margin-start: 1em; + flex: 0 1 auto; + line-height: 24px; + vertical-align: middle; + } + + :host .configuring-failed-text { + color: red; + font-style: italic; + } + + :host([stale_]) :-webkit-any(.icon, .name, .connection-status) { + opacity: 0.4; + } + </style> + <img class="icon" src="[[destination.iconUrl]]" + srcset="[[destination.srcSet]]"> + <span class="name searchable">[[destination.displayName]]</span> + <span class="search-hint searchable">[[searchHint_]]</span> + <span class="connection-status" + hidden$="[[!destination.isOfflineOrInvalid]]"> + [[destination.connectionStatusText]] + </span> + <a is="action-link" class="learn-more-link" + hidden$="[[!destination.shouldShowInvalidCertificateError]]"> + $i18n{learnMore} + </a> + <span class="register-promo" hidden$="[[!destination.isUnregistered]]"> + <button class="register-promo-button"> + $i18n{registerPromoButtonText} + </button> + </span> + <span class="extension-controlled-indicator" + hidden$="[[!destination.isExtension]]"> + <span class="extension-name searchable"> + [[destination.extensionName]] + </span> + <span class="extension-icon" role="button" tabindex="0"></span> + </span> +<if expr="chromeos"> + <span class="configuring-in-progress-text" hidden> + $i18n{configuringInProgressText} + <span class="configuring-text-jumping-dots"> + <span>.</span><span>.</span><span>.</span> + </span> + </span> + <span class="configuring-failed-text" hidden> + $i18n{configuringFailedText} + </span> +</if> + </template> + <script src="destination_list_item.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_list_item.js b/chromium/chrome/browser/resources/print_preview/new/destination_list_item.js new file mode 100644 index 00000000000..7e790c0644e --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/destination_list_item.js @@ -0,0 +1,58 @@ +// Copyright 2018 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. + +Polymer({ + is: 'print-preview-destination-list-item', + + properties: { + /** @type {!print_preview.Destination} */ + destination: Object, + + /** @type {?RegExp} */ + searchQuery: Object, + + /** @private */ + stale_: { + type: Boolean, + reflectToAttribute: true, + }, + + /** @private {string} */ + searchHint_: String, + }, + + observers: [ + 'onDestinationPropertiesChange_(' + + 'destination.displayName, destination.isOfflineOrInvalid)', + ], + + /** @private {boolean} */ + highlighted_: false, + + /** @private */ + onDestinationPropertiesChange_: function() { + this.title = this.destination.displayName; + this.stale_ = this.destination.isOfflineOrInvalid; + }, + + update: function() { + this.updateSearchHint_(); + this.updateHighlighting_(); + }, + + /** @private */ + updateSearchHint_: function() { + this.searchHint_ = !this.searchQuery ? + '' : + this.destination.extraPropertiesToMatch + .filter(p => p.match(this.searchQuery)) + .join(' '); + }, + + /** @private */ + updateHighlighting_: function() { + this.highlighted_ = print_preview.updateHighlights( + this, this.searchQuery, this.highlighted_); + }, +}); diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_settings.html b/chromium/chrome/browser/resources/print_preview/new/destination_settings.html index 69f1f01c23f..b95e9e8be34 100644 --- a/chromium/chrome/browser/resources/print_preview/new/destination_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/destination_settings.html @@ -1,8 +1,13 @@ <link rel="import" href="chrome://resources/html/polymer.html"> +<link rel="import" href="chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.html"> <link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> -<link rel="import" href="button_css.html"> +<link rel="import" href="chrome://resources/html/event_tracker.html"> <link rel="import" href="../data/destination.html"> +<link rel="import" href="../data/destination_store.html"> +<link rel="import" href="../data/user_info.html"> +<link rel="import" href="button_css.html"> +<link rel="import" href="destination_dialog.html"> <link rel="import" href="print_preview_shared_css.html"> <link rel="import" href="throbber_css.html"> <link rel="import" href="settings_section.html"> @@ -69,15 +74,28 @@ <img class="destination-icon" src="[[destination.iconUrl]]" alt=""> <div class="destination-info-wrapper"> - <div class="destination-name">[[destination.id]]</div> + <div class="destination-name">[[destination.displayName]]</div> <div class="destination-location">[[destination.hint]]</div> <div class="destination-connection-status"> [[destination.connectionStatusText]]</div> </div> </div> - <button>$i18n{changeDestination}</button> + <button + disabled$="[[shouldDisableButton_(destinationStore, disabled, + state)]]" + on-click="onChangeButtonClick_"> + $i18n{changeDestination} + </button> </div> </print-preview-settings-section> + <template is="cr-lazy-render" id="destinationDialog"> + <print-preview-destination-dialog + destination-store="[[destinationStore]]" + recent-destinations="[[recentDestinations]]" + user-info="{{userInfo}}" + show-cloud-print-promo="{{showCloudPrintPromo_}}"> + </print-preview-destination-dialog> + </template> </template> <script src="destination_settings.js"></script> </dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/destination_settings.js b/chromium/chrome/browser/resources/print_preview/new/destination_settings.js index 6fde6cc0895..e61f33c637c 100644 --- a/chromium/chrome/browser/resources/print_preview/new/destination_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/destination_settings.js @@ -9,19 +9,72 @@ Polymer({ /** @type {!print_preview.Destination} */ destination: Object, + /** @type {?print_preview.DestinationStore} */ + destinationStore: Object, + + /** @type {!Array<!print_preview.RecentDestination>} */ + recentDestinations: Array, + + /** @type {!print_preview.UserInfo} */ + userInfo: { + type: Object, + notify: true, + }, + + disabled: Boolean, + + /** @type {!print_preview_new.State} */ + state: Number, + + /** @private {boolean} */ + showCloudPrintPromo_: { + type: Boolean, + value: false, + }, + /** @private {boolean} */ - loadingDestination_: Boolean, + loadingDestination_: { + type: Boolean, + value: true, + }, }, - /** @override */ - ready: function() { - this.loadingDestination_ = true; - // Simulate transition from spinner to destination. - setTimeout(this.doneLoading_.bind(this), 5000); + observers: ['onDestinationSet_(destination, destination.id)'], + + /** + * @return {boolean} Whether the destination change button should be disabled. + * @private + */ + shouldDisableButton_: function() { + return !this.destinationStore || + (this.disabled && + this.state != print_preview_new.State.INVALID_PRINTER); }, /** @private */ - doneLoading_: function() { - this.loadingDestination_ = false; + onDestinationSet_: function() { + if (this.destination && this.destination.id) + this.loadingDestination_ = false; + }, + + /** @private */ + onChangeButtonClick_: function() { + this.destinationStore.startLoadAllDestinations(); + const dialog = this.$.destinationDialog.get(); + // This async() call is a workaround to prevent a DCHECK - see + // https://crbug.com/804047. + this.async(() => { + dialog.show(); + }, 1); + }, + + showCloudPrintPromo: function() { + this.showCloudPrintPromo_ = true; + }, + + /** @return {boolean} Whether the destinations dialog is open. */ + isDialogOpen: function() { + const destinationDialog = this.$$('print-preview-destination-dialog'); + return destinationDialog && destinationDialog.isOpen(); }, }); diff --git a/chromium/chrome/browser/resources/print_preview/new/dpi_settings.html b/chromium/chrome/browser/resources/print_preview/new/dpi_settings.html index 2e448060168..1714dc87c91 100644 --- a/chromium/chrome/browser/resources/print_preview/new/dpi_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/dpi_settings.html @@ -15,7 +15,7 @@ <div slot="controls"> <print-preview-settings-select aria-labelled-by="dpi-label" capability="[[capabilityWithLabels_]]" setting-name="dpi" - settings="{{settings}}"> + settings="{{settings}}" disabled="[[disabled]]"> </print-preview-settings-select> </div> </print-preview-settings-section> diff --git a/chromium/chrome/browser/resources/print_preview/new/dpi_settings.js b/chromium/chrome/browser/resources/print_preview/new/dpi_settings.js index c5d1c8d9b11..31e95f6d35b 100644 --- a/chromium/chrome/browser/resources/print_preview/new/dpi_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/dpi_settings.js @@ -30,6 +30,8 @@ Polymer({ /** @type {{ option: Array<!print_preview_new.SelectOption> }} */ capability: Object, + disabled: Boolean, + /** @private {{ option: Array<!print_preview_new.SelectOption> }} */ capabilityWithLabels_: { type: Object, diff --git a/chromium/chrome/browser/resources/print_preview/new/header.html b/chromium/chrome/browser/resources/print_preview/new/header.html index e16008da496..70468e7670d 100644 --- a/chromium/chrome/browser/resources/print_preview/new/header.html +++ b/chromium/chrome/browser/resources/print_preview/new/header.html @@ -1,9 +1,11 @@ <link rel="import" href="chrome://resources/html/polymer.html"> +<link rel="import" href="chrome://resources/html/cr.html"> <link rel="import" href="button_css.html"> <link rel="import" href="../data/destination.html"> <link rel="import" href="settings_behavior.html"> <link rel="import" href="print_preview_shared_css.html"> +<link rel="import" href="state.html"> <link rel="import" href="strings.html"> <dom-module id="print-preview-header"> @@ -67,16 +69,13 @@ } </style> <h1 class="title">$i18n{title}</h1> - <span class="summary" - aria-label="[[getSummaryLabel_(currentErrorOrState_, labelInfo_)]]" - inner-h-t-m-l="[[getSummary_(currentErrorOrState_, labelInfo_)]]"> + <span class="summary" aria-label$="[[summaryLabel_]]" + inner-h-t-m-l="[[summary_]]"> </span> <div id="button-strip"> - <button class="cancel" on-tap="onCancelButtonTap_"> - $i18n{cancel} - </button> - <button class="print default" on-tap="onPrintButtonTap_" - disabled$="[[printButtonDisabled_(currentErrorOrState_)]]"> + <button class="cancel" on-click="onCancelClick_">$i18n{cancel}</button> + <button class="print default" on-click="onPrintClick_" + disabled$="[[!printButtonEnabled_]]"> [[getPrintButton_(destination.id)]] </button> </div> diff --git a/chromium/chrome/browser/resources/print_preview/new/header.js b/chromium/chrome/browser/resources/print_preview/new/header.js index 00ebc16df7f..b1bf7920e69 100644 --- a/chromium/chrome/browser/resources/print_preview/new/header.js +++ b/chromium/chrome/browser/resources/print_preview/new/header.js @@ -2,6 +2,16 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +cr.exportPath('print_preview_new.Header'); + +/** + * @typedef {{numPages: number, + * numSheets: number, + * pagesLabel: string, + * summaryLabel: string}} + */ +print_preview_new.Header.LabelInfo; + Polymer({ is: 'print-preview-header', @@ -12,51 +22,43 @@ Polymer({ destination: Object, /** @type {!print_preview_new.State} */ - state: { - type: Object, - notify: true, - }, + state: Number, /** @private {boolean} */ - printInProgress_: { + printButtonEnabled_: { type: Boolean, - notify: true, value: false, }, - /** - * @private {?string} Null value indicates that there is no error or - * state to display in the summary. - */ - currentErrorOrState_: { + /** @private {?string} */ + summary_: { type: String, - computed: 'computeErrorOrStateString_(state.*, ' + - 'settings.copies.valid, settings.scaling.valid, ' + - 'settings.pages.valid, printInProgress_)' + notify: true, + value: null, }, - /** - * @private {{numPages: number, - * numSheets: number, - * pagesLabel: string, - * summaryLabel: string}} - */ - labelInfo_: { - type: Object, - computed: 'getLabelInfo_(currentErrorOrState_, destination.id, ' + - 'settings.copies.value, settings.pages.value, ' + - 'settings.duplex.value)' + /** @private {?string} */ + summaryLabel_: { + type: String, + notify: true, + value: null, }, + + errorMessage: String, }, + observers: + ['update_(settings.copies.value, settings.duplex.value, ' + + 'settings.pages.value, state)'], + /** @private */ - onPrintButtonTap_: function() { - this.printInProgress_ = true; + onPrintClick_: function() { + this.fire('print-requested'); }, /** @private */ - onCancelButtonTap_: function() { - this.set('state.cancelled', true); + onCancelClick_: function() { + this.fire('cancel-requested'); }, /** @@ -81,34 +83,10 @@ Polymer({ }, /** - * @return {?string} + * @return {!print_preview_new.Header.LabelInfo} * @private */ - computeErrorOrStateString_: function() { - if (this.state.cloudPrintError != '') - return this.state.cloudPrintError; - if (this.state.privetExtensionError != '') - return this.state.privetExtensionError; - if (this.state.invalidSettings || this.state.previewFailed || - this.state.previewLoading || !this.getSetting('copies').valid || - !this.getSetting('scaling').valid || !this.getSetting('pages').valid) { - return ''; - } - if (this.printInProgress_) { - return loadTimeData.getString( - this.isPdfOrDrive_() ? 'saving' : 'printing'); - } - return null; - }, - - /** - * @return {{numPages: number, - * numSheets: number, - * pagesLabel: string, - * summaryLabel: string}} - * @private - */ - getLabelInfo_: function() { + computeLabelInfo_: function() { const saveToPdfOrDrive = this.isPdfOrDrive_(); let numPages = this.getSetting('pages').value.length; let numSheets = numPages; @@ -139,23 +117,41 @@ Polymer({ }; }, - /** - * @return {boolean} - * @private - */ - printButtonDisabled_: function() { - return this.currentErrorOrState_ != null; + /** @private */ + update_: function() { + switch (this.state) { + case (print_preview_new.State.PRINTING): + this.printButtonEnabled_ = false; + this.summary_ = loadTimeData.getString( + this.isPdfOrDrive_() ? 'saving' : 'printing'); + this.summaryLabel_ = this.summary_; + break; + case (print_preview_new.State.READY): + this.printButtonEnabled_ = true; + const labelInfo = this.computeLabelInfo_(); + this.summary_ = this.getSummary_(labelInfo); + this.summaryLabel_ = this.getSummaryLabel_(labelInfo); + break; + case (print_preview_new.State.FATAL_ERROR): + this.printButtonEnabled_ = false; + this.summary_ = this.errorMessage; + this.summaryLabel_ = this.errorMessage; + break; + default: + this.summary_ = null; + this.summaryLabel_ = null; + this.printButtonEnabled_ = false; + break; + } }, /** + * @param {!print_preview_new.Header.LabelInfo} labelInfo * @return {string} * @private */ - getSummary_: function() { - let html = this.currentErrorOrState_; - if (html != null) - return html; - const labelInfo = this.labelInfo_; + getSummary_: function(labelInfo) { + let html = null; if (labelInfo.numPages != labelInfo.numSheets) { html = loadTimeData.getStringF( 'printPreviewSummaryFormatLong', @@ -175,13 +171,11 @@ Polymer({ }, /** + * @param {!print_preview_new.Header.LabelInfo} labelInfo * @return {string} * @private */ - getSummaryLabel_: function() { - if (this.currentErrorOrState_ != null) - return this.currentErrorOrState_; - const labelInfo = this.labelInfo_; + getSummaryLabel_: function(labelInfo) { if (labelInfo.numPages != labelInfo.numSheets) { return loadTimeData.getStringF( 'printPreviewSummaryFormatLong', labelInfo.numSheets.toLocaleString(), diff --git a/chromium/chrome/browser/resources/print_preview/new/highlight_utils.html b/chromium/chrome/browser/resources/print_preview/new/highlight_utils.html new file mode 100644 index 00000000000..ad6c80409c2 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/highlight_utils.html @@ -0,0 +1,3 @@ +<link rel="import" href="chrome://resources/html/search_highlight_utils.html"> + +<script src="highlight_utils.js"></script> diff --git a/chromium/chrome/browser/resources/print_preview/new/highlight_utils.js b/chromium/chrome/browser/resources/print_preview/new/highlight_utils.js new file mode 100644 index 00000000000..0a8df2f4780 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/highlight_utils.js @@ -0,0 +1,60 @@ +// Copyright 2018 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. + +cr.define('print_preview', function() { + 'use strict'; + + /** + * @param {!HTMLElement} element The element to update. Element should have a + * shadow root. + * @param {?RegExp} query The current search query + * @param {boolean} wasHighlighted Whether the element was previously + * highlighted. + * @return {boolean} Whether the element is highlighted after the update. + */ + function updateHighlights(element, query, wasHighlighted) { + if (wasHighlighted) { + cr.search_highlight_utils.findAndRemoveHighlights(element); + cr.search_highlight_utils.findAndRemoveBubbles(element); + } + + if (!query) + return false; + + let isHighlighted = false; + element.shadowRoot.querySelectorAll('.searchable').forEach(childElement => { + childElement.childNodes.forEach(node => { + if (node.nodeType != Node.TEXT_NODE) + return; + + const textContent = node.nodeValue.trim(); + if (textContent.length == 0) + return; + + if (query.test(textContent)) { + isHighlighted = true; + // Don't highlight <select> nodes, yellow rectangles can't be + // displayed within an <option>. + if (node.parentNode.nodeName != 'OPTION') { + cr.search_highlight_utils.highlight(node, textContent.split(query)); + } else { + const selectNode = node.parentNode.parentNode; + // The bubble should be parented by the select node's parent. + // Note: The bubble's ::after element, a yellow arrow, will not + // appear correctly in print preview without SPv175 enabled. See + // https://crbug.com/817058. + cr.search_highlight_utils.highlightControlWithBubble( + /** @type {!HTMLElement} */ (assert(selectNode.parentNode)), + textContent.match(query)[0]); + } + } + }); + }); + return isHighlighted; + } + + return { + updateHighlights: updateHighlights, + }; +}); diff --git a/chromium/chrome/browser/resources/print_preview/new/layout_settings.html b/chromium/chrome/browser/resources/print_preview/new/layout_settings.html index afbc18b2d85..0b0e7ae3098 100644 --- a/chromium/chrome/browser/resources/print_preview/new/layout_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/layout_settings.html @@ -11,7 +11,8 @@ <print-preview-settings-section> <span id="layout-label" slot="title">$i18n{layoutLabel}</span> <div slot="controls"> - <select aria-labelledby="layout-label" on-change="onChange_"> + <select aria-labelledby="layout-label" on-change="onChange_" + disabled$="[[disabled]]"> <option value="portrait" selected>$i18n{optionPortrait}</option> <option value="landscape">$i18n{optionLandscape}</option> </select> diff --git a/chromium/chrome/browser/resources/print_preview/new/layout_settings.js b/chromium/chrome/browser/resources/print_preview/new/layout_settings.js index 77d431925c4..b84fbe2bd00 100644 --- a/chromium/chrome/browser/resources/print_preview/new/layout_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/layout_settings.js @@ -7,6 +7,10 @@ Polymer({ behaviors: [SettingsBehavior], + properties: { + disabled: Boolean, + }, + observers: ['onLayoutSettingChange_(settings.layout.value)'], /** diff --git a/chromium/chrome/browser/resources/print_preview/new/margins_settings.html b/chromium/chrome/browser/resources/print_preview/new/margins_settings.html index dbc9ac60d9c..7bf74d1b5a3 100644 --- a/chromium/chrome/browser/resources/print_preview/new/margins_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/margins_settings.html @@ -12,7 +12,8 @@ <print-preview-settings-section> <span id="margins-label" slot="title">$i18n{marginsLabel}</span> <div slot="controls"> - <select aria-labelledby="margins-label" on-change="onChange_"> + <select aria-labelledby="margins-label" on-change="onChange_" + disabled$="[[disabled]]"> <!-- The order of these options must match the natural order of their values, which come from print_preview.ticket_items.MarginsTypeValue. --> diff --git a/chromium/chrome/browser/resources/print_preview/new/margins_settings.js b/chromium/chrome/browser/resources/print_preview/new/margins_settings.js index 65ec5a10e3e..c641b40c73b 100644 --- a/chromium/chrome/browser/resources/print_preview/new/margins_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/margins_settings.js @@ -7,6 +7,10 @@ Polymer({ behaviors: [SettingsBehavior], + properties: { + disabled: Boolean, + }, + observers: ['onMarginsSettingChange_(settings.margins.value)'], /** diff --git a/chromium/chrome/browser/resources/print_preview/new/media_size_settings.html b/chromium/chrome/browser/resources/print_preview/new/media_size_settings.html index 540f8f0972c..03223dfdce2 100644 --- a/chromium/chrome/browser/resources/print_preview/new/media_size_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/media_size_settings.html @@ -14,7 +14,7 @@ <div slot="controls"> <print-preview-settings-select aria-labelledby="media-size-label" capability="[[capability]]" setting-name="mediaSize" - settings="{{settings}}"> + settings="{{settings}}" disabled="[[disabled]]"> </print-preview-settings-select> </div> </print-preview-settings-section> diff --git a/chromium/chrome/browser/resources/print_preview/new/media_size_settings.js b/chromium/chrome/browser/resources/print_preview/new/media_size_settings.js index 55dc7ebcb8b..1ff1bfb0715 100644 --- a/chromium/chrome/browser/resources/print_preview/new/media_size_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/media_size_settings.js @@ -9,6 +9,8 @@ Polymer({ properties: { capability: Object, + + disabled: Boolean, }, observers: diff --git a/chromium/chrome/browser/resources/print_preview/new/model.html b/chromium/chrome/browser/resources/print_preview/new/model.html index 30924113655..3e3ea6386f2 100644 --- a/chromium/chrome/browser/resources/print_preview/new/model.html +++ b/chromium/chrome/browser/resources/print_preview/new/model.html @@ -1,6 +1,7 @@ <link rel="import" href="chrome://resources/html/polymer.html"> <link rel="import" href="chrome://resources/html/cr.html"> +<link rel="import" href="settings_behavior.html"> <link rel="import" href="../data/destination.html"> <link rel="import" href="../data/document_info.html"> <link rel="import" href="../data/margins.html"> diff --git a/chromium/chrome/browser/resources/print_preview/new/model.js b/chromium/chrome/browser/resources/print_preview/new/model.js index bf4fe074786..0b90df49409 100644 --- a/chromium/chrome/browser/resources/print_preview/new/model.js +++ b/chromium/chrome/browser/resources/print_preview/new/model.js @@ -33,6 +33,16 @@ cr.exportPath('print_preview_new'); */ print_preview_new.SerializedSettings; +/** + * Constant values matching printing::DuplexMode enum. + * @enum {number} + */ +print_preview_new.DuplexMode = { + SIMPLEX: 0, + LONG_EDGE: 1, + UNKNOWN_DUPLEX_MODE: -1 +}; + (function() { 'use strict'; @@ -42,16 +52,28 @@ const NUM_DESTINATIONS = 3; /** * Sticky setting names. Alphabetical except for fitToPage, which must be set * after scaling in updateFromStickySettings(). - * @type {Array<string>} + * @type {!Array<string>} */ const STICKY_SETTING_NAMES = [ - 'collate', 'color', 'cssBackground', 'dpi', 'duplex', 'headerFooter', - 'layout', 'margins', 'mediaSize', 'scaling', 'fitToPage' + 'collate', + 'color', + 'cssBackground', + 'dpi', + 'duplex', + 'headerFooter', + 'layout', + 'margins', + 'mediaSize', + 'scaling', + 'fitToPage', + 'vendorItems', ]; Polymer({ is: 'print-preview-model', + behaviors: [SettingsBehavior], + properties: { /** * Object containing current settings of Print Preview, for use by Polymer @@ -175,7 +197,7 @@ Polymer({ unavailableValue: {}, valid: true, available: true, - key: '', + key: 'vendorOptions', }, // This does not represent a real setting value, and is used only to // expose the availability of the other options settings section. @@ -228,12 +250,19 @@ Polymer({ 'settings.mediaSize.value, settings.margins.value, ' + 'settings.dpi.value, settings.fitToPage.value, ' + 'settings.scaling.value, settings.duplex.value, ' + - 'settings.headerFooter.value, settings.cssBackground.value)', + 'settings.headerFooter.value, settings.cssBackground.value, ' + + 'settings.vendorItems.value)', ], /** @private {boolean} */ initialized_: false, + /** @private {?print_preview_new.SerializedSettings} */ + stickySettings_: null, + + /** @private {?print_preview.Cdd} */ + lastDestinationCapabilities_: null, + /** * Updates the availability of the settings sections and values of dpi and * media size settings. @@ -244,6 +273,14 @@ Polymer({ this.destination.capabilities.printer : null; this.updateSettingsAvailability_(caps); + + if (!caps) + return; + + if (this.destination.capabilities == this.lastDestinationCapabilities_) + return; + + this.lastDestinationCapabilities_ = this.destination.capabilities; this.updateSettingsValues_(caps); }, @@ -290,6 +327,8 @@ Polymer({ this.settings.selectionOnly.available || this.settings.headerFooter.available || this.settings.rasterize.available); + this.set( + 'settings.vendorItems.available', !!caps && !!caps.vendor_capability); }, /** @@ -318,25 +357,38 @@ Polymer({ */ updateSettingsValues_: function(caps) { if (this.settings.mediaSize.available) { - for (const option of caps.media_size.option) { - if (option.is_default) { - this.set('settings.mediaSize.value', option); - break; - } - } + const defaultOption = caps.media_size.option.find(o => !!o.is_default); + this.set('settings.mediaSize.value', defaultOption); } - if (this.settings.dpi.available) { - for (const option of caps.dpi.option) { - if (option.is_default) { - this.set('settings.dpi.value', option); - break; - } - } + const defaultOption = caps.dpi.option.find(o => !!o.is_default); + this.set('settings.dpi.value', defaultOption); } else if ( caps && caps.dpi && caps.dpi.option && caps.dpi.option.length > 0) { this.set('settings.dpi.value', caps.dpi.option[0]); } + + if (this.settings.vendorItems.available) { + const vendorSettings = {}; + for (const item of caps.vendor_capability) { + let defaultValue = null; + if (item.type == 'SELECT' && !!item.select_cap && + !!item.select_cap.option) { + const defaultOption = + item.select_cap.option.find(o => !!o.is_default); + defaultValue = !!defaultOption ? defaultOption.value : null; + } else if (item.type == 'RANGE') { + if (!!item.range_cap) + defaultValue = item.range_cap.default || null; + } else if (item.type == 'TYPED_VALUE') { + if (!!item.typed_value_cap) + defaultValue = item.typed_value_cap.default || null; + } + if (defaultValue != null) + vendorSettings[item.id] = defaultValue; + } + this.set('settings.vendorItems.value', vendorSettings); + } }, /** @private */ @@ -371,18 +423,20 @@ Polymer({ this.recentDestinations.splice(indexFound, 1); // Add the most recent destination - this.recentDestinations.splice(0, 0, newDestination); - this.notifyPath('recentDestinations'); + this.splice('recentDestinations', 0, 0, newDestination); // Persist sticky settings. this.stickySettingsChanged_(); }, /** + * Caches the sticky settings and sets up the recent destinations. Sticky + * settings will be applied when destinaton capabilities have been retrieved. * @param {?string} savedSettingsStr The sticky settings from native layer */ - updateFromStickySettings: function(savedSettingsStr) { - this.initialized_ = true; + setStickySettings: function(savedSettingsStr) { + assert(!this.stickySettings_ && this.recentDestinations.length == 0); + if (!savedSettingsStr) return; @@ -403,16 +457,26 @@ Polymer({ } this.recentDestinations = recentDestinations; - // Reset initialized, or stickySettingsChanged_ will get called for - // every setting that gets set below. - this.initialized_ = false; - STICKY_SETTING_NAMES.forEach(settingName => { - const setting = this.get(settingName, this.settings); - const value = savedSettings[setting.key]; - if (value != undefined) - this.set(`settings.${settingName}.value`, value); - }); + this.stickySettings_ = savedSettings; + }, + + applyStickySettings: function() { + if (this.stickySettings_) { + STICKY_SETTING_NAMES.forEach(settingName => { + const setting = this.get(settingName, this.settings); + const value = this.stickySettings_[setting.key]; + if (value != undefined) + this.set(`settings.${settingName}.value`, value); + }); + } this.initialized_ = true; + this.stickySettings_ = null; + this.stickySettingsChanged_(); + }, + + /** @return {boolean} Whether the model has been initialized. */ + initialized: function() { + return this.initialized_; }, /** @private */ @@ -431,5 +495,168 @@ Polymer({ }); this.fire('save-sticky-settings', JSON.stringify(serialization)); }, + + /** + * Creates a string that represents a print ticket. + * @param {!print_preview.Destination} destination Destination to print to. + * @return {string} Serialized print ticket. + */ + createPrintTicket: function(destination) { + const dpi = /** @type {{horizontal_dpi: (number | undefined), + vertical_dpi: (number | undefined), + vendor_id: (number | undefined)}} */ ( + this.getSettingValue('dpi')); + + const ticket = { + mediaSize: this.getSettingValue('mediaSize'), + pageCount: this.getSettingValue('pages').length, + landscape: this.getSettingValue('layout'), + color: destination.getNativeColorModel( + /** @type {boolean} */ (this.getSettingValue('color'))), + headerFooterEnabled: false, // only used in print preview + marginsType: this.getSettingValue('margins'), + duplex: this.getSettingValue('duplex') ? + print_preview_new.DuplexMode.LONG_EDGE : + print_preview_new.DuplexMode.SIMPLEX, + copies: this.getSettingValue('copies'), + collate: this.getSettingValue('collate'), + shouldPrintBackgrounds: this.getSettingValue('cssBackground'), + shouldPrintSelectionOnly: false, // only used in print preview + previewModifiable: this.documentInfo.isModifiable, + printToPDF: destination.id == + print_preview.Destination.GooglePromotedId.SAVE_AS_PDF, + printWithCloudPrint: !destination.isLocal, + printWithPrivet: destination.isPrivet, + printWithExtension: destination.isExtension, + rasterizePDF: this.getSettingValue('rasterize'), + scaleFactor: parseInt(this.getSettingValue('scaling'), 10), + dpiHorizontal: (dpi && 'horizontal_dpi' in dpi) ? dpi.horizontal_dpi : 0, + dpiVertical: (dpi && 'vertical_dpi' in dpi) ? dpi.vertical_dpi : 0, + deviceName: destination.id, + fitToPageEnabled: this.getSettingValue('fitToPage'), + pageWidth: this.documentInfo.pageSize.width, + pageHeight: this.documentInfo.pageSize.height, + showSystemDialog: false, + }; + + // Set 'cloudPrintID' only if the destination is not local. + if (!destination.isLocal) + ticket.cloudPrintID = destination.id; + + if (this.getSettingValue('margins') == + print_preview.ticket_items.MarginsTypeValue.CUSTOM) { + // TODO (rbpotter): Replace this with real values when custom margins are + // implemented. + ticket.marginsCustom = { + marginTop: 70, + marginRight: 70, + marginBottom: 70, + marginLeft: 70, + }; + } + + if (destination.isPrivet || destination.isExtension) { + // TODO (rbpotter): Get local and PDF printers to use the same ticket and + // send only this ticket instead of nesting it in a larger ticket. + ticket.ticket = this.createCloudJobTicket(destination); + ticket.capabilities = JSON.stringify(destination.capabilities); + } + return JSON.stringify(ticket); + }, + + /** + * Creates an object that represents a Google Cloud Print print ticket. + * @param {!print_preview.Destination} destination Destination to print to. + * @return {string} Google Cloud Print print ticket. + */ + createCloudJobTicket: function(destination) { + assert( + !destination.isLocal || destination.isPrivet || destination.isExtension, + 'Trying to create a Google Cloud Print print ticket for a local ' + + ' non-privet and non-extension destination'); + assert( + destination.capabilities, + 'Trying to create a Google Cloud Print print ticket for a ' + + 'destination with no print capabilities'); + + // Create CJT (Cloud Job Ticket) + const cjt = {version: '1.0', print: {}}; + if (this.settings.collate.available) + cjt.print.collate = {collate: this.settings.collate.value}; + if (this.settings.color.available) { + const selectedOption = destination.getSelectedColorOption( + /** @type {boolean} */ (this.settings.color.value)); + if (!selectedOption) { + console.error('Could not find correct color option'); + } else { + cjt.print.color = {type: selectedOption.type}; + if (selectedOption.hasOwnProperty('vendor_id')) { + cjt.print.color.vendor_id = selectedOption.vendor_id; + } + } + } else { + // Always try setting the color in the print ticket, otherwise a + // reasonable reader of the ticket will have to do more work, or process + // the ticket sub-optimally, in order to safely handle the lack of a + // color ticket item. + const defaultOption = destination.defaultColorOption; + if (defaultOption) { + cjt.print.color = {type: defaultOption.type}; + if (defaultOption.hasOwnProperty('vendor_id')) { + cjt.print.color.vendor_id = defaultOption.vendor_id; + } + } + } + if (this.settings.copies.available) + cjt.print.copies = {copies: this.settings.copies.value}; + if (this.settings.duplex.available) { + cjt.print.duplex = { + type: this.settings.duplex.value ? 'LONG_EDGE' : 'NO_DUPLEX' + }; + } + if (this.settings.mediaSize.available) { + const mediaValue = this.settings.mediaSize.value; + cjt.print.media_size = { + width_microns: mediaValue.width_microns, + height_microns: mediaValue.height_microns, + is_continuous_feed: mediaValue.is_continuous_feed, + vendor_id: mediaValue.vendor_id + }; + } + if (!this.settings.layout.available) { + // In this case "orientation" option is hidden from user, so user can't + // adjust it for page content, see Landscape.isCapabilityAvailable(). + // We can improve results if we set AUTO here. + const capability = destination.capabilities.printer ? + destination.capabilities.printer.page_orientation : + null; + if (capability && capability.option && + capability.option.some(option => option.type == 'AUTO')) { + cjt.print.page_orientation = {type: 'AUTO'}; + } + } else { + cjt.print.page_orientation = { + type: this.settings.layout ? 'LANDSCAPE' : 'PORTRAIT' + }; + } + if (this.settings.dpi.available) { + const dpiValue = this.settings.dpi.value; + cjt.print.dpi = { + horizontal_dpi: dpiValue.horizontal_dpi, + vertical_dpi: dpiValue.vertical_dpi, + vendor_id: dpiValue.vendor_id + }; + } + if (this.settings.vendorItems.available) { + const items = this.settings.vendorItems.value; + cjt.print.vendor_ticket_item = []; + for (const itemId in items) { + if (items.hasOwnProperty(itemId)) { + cjt.print.vendor_ticket_item.push({id: itemId, value: items[itemId]}); + } + } + } + return JSON.stringify(cjt); + }, }); })(); diff --git a/chromium/chrome/browser/resources/print_preview/new/number_settings_section.html b/chromium/chrome/browser/resources/print_preview/new/number_settings_section.html index dfea6be0f89..c56de1f3557 100644 --- a/chromium/chrome/browser/resources/print_preview/new/number_settings_section.html +++ b/chromium/chrome/browser/resources/print_preview/new/number_settings_section.html @@ -32,13 +32,16 @@ <div slot="controls"> <slot name="opt-outside-content"></slot> <span class="input-wrapper"> - <input class="user-value" type="number" value="{{inputString::input}}" - max="[[maxValue]]" min="[[minValue]]" on-blur="onBlur_" - on-keydown="onKeydown_" aria-labelled-by="section-title"> + <input class="user-value" type="number" + value="{{inputString_::input}}" + max="[[maxValue]]" min="[[minValue]]" + disabled$="[[getDisabled_(inputValid, disabled)]]" + on-blur="onBlur_" on-keydown="onKeydown_" + aria-labelled-by="section-title"> <slot name="opt-inside-content"></slot> </span> <span class="hint" aria-live="polite" - hidden$="[[hintHidden_(inputString, inputValid)]]"> + hidden$="[[hintHidden_(inputString_, inputValid)]]"> [[hintMessage]] </span> </div> diff --git a/chromium/chrome/browser/resources/print_preview/new/number_settings_section.js b/chromium/chrome/browser/resources/print_preview/new/number_settings_section.js index 4084b852b71..ed173077085 100644 --- a/chromium/chrome/browser/resources/print_preview/new/number_settings_section.js +++ b/chromium/chrome/browser/resources/print_preview/new/number_settings_section.js @@ -6,33 +6,46 @@ Polymer({ is: 'print-preview-number-settings-section', properties: { - /** @type {string} */ - inputString: { + /** @private {string} */ + inputString_: { type: String, notify: true, + observer: 'onInputChanged_', }, /** @type {boolean} */ inputValid: { type: Boolean, notify: true, - computed: 'computeValid_(inputString)', + value: true, }, /** @type {string} */ + currentValue: { + type: String, + notify: true, + observer: 'onCurrentValueChanged_', + }, + defaultValue: String, - /** @type {number} */ maxValue: Number, - /** @type {number} */ minValue: Number, - /** @type {string} */ inputLabel: String, - /** @type {string} */ hintMessage: String, + + disabled: Boolean, + }, + + /** + * @return {boolean} Whether the input should be disabled. + * @private + */ + getDisabled_: function() { + return this.disabled && this.inputValid; }, /** @@ -45,19 +58,31 @@ Polymer({ /** @private */ onBlur_: function() { - if (this.inputString == '') - this.set('inputString', this.defaultValue); + if (this.inputString_ == '') + this.set('inputString_', this.defaultValue); + }, + + /** @private */ + onInputChanged_: function() { + this.inputValid = this.computeValid_(); + if (this.inputValid) + this.currentValue = this.inputString_; + }, + + /** @private */ + onCurrentValueChanged_: function() { + this.inputString_ = this.currentValue; }, /** - * @return {boolean} Whether input value represented by inputString is + * @return {boolean} Whether input value represented by inputString_ is * valid. * @private */ computeValid_: function() { - // Make sure value updates first, in case inputString was updated by JS. - this.$$('.user-value').value = this.inputString; - return this.$$('.user-value').validity.valid && this.inputString != ''; + // Make sure value updates first, in case inputString_ was updated by JS. + this.$$('.user-value').value = this.inputString_; + return this.$$('.user-value').validity.valid && this.inputString_ != ''; }, /** @@ -65,6 +90,6 @@ Polymer({ * @private */ hintHidden_: function() { - return this.inputValid || this.inputString == ''; + return this.inputValid || this.inputString_ == ''; }, }); diff --git a/chromium/chrome/browser/resources/print_preview/new/other_options_settings.html b/chromium/chrome/browser/resources/print_preview/new/other_options_settings.html index b816a3274e9..afd187b856d 100644 --- a/chromium/chrome/browser/resources/print_preview/new/other_options_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/other_options_settings.html @@ -19,12 +19,14 @@ <label aria-live="polite" hidden$="[[!settings.headerFooter.available]]"> <input type="checkbox" id="header-footer" + disabled$="[[disabled]]" on-change="onHeaderFooterChange_" checked$="[[settings.headerFooter.value]]"> <span>$i18n{optionHeaderFooter}</span> </label> <label aria-live="polite" hidden$="[[!settings.duplex.available]]"> <input type="checkbox" id="duplex" on-change="onDuplexChange_" + disabled$="[[disabled]]" checked$="[[settings.duplex.value]]"> <span>$i18n{optionTwoSided}</span> </label> @@ -32,18 +34,20 @@ hidden$="[[!settings.cssBackground.available]]"> <input type="checkbox" id="css-background" on-change="onCssBackgroundChange_" + disabled$="[[disabled]]" checked$="[[settings.cssBackground.value]]"> <span>$i18n{optionBackgroundColorsAndImages}</span> </label> <label aria-live="polite" hidden$="[[!settings.rasterize.available]]"> <input type="checkbox" id="rasterize" - on-change="onRasterizeChange_" + disabled$="[[disabled]]" on-change="onRasterizeChange_" checked$="[[settings.rasterize.value]]"> <span>$i18n{optionRasterize}</span> </label> <label aria-live="polite" hidden$="[[!settings.selectionOnly.available]]"> <input type="checkbox" id="selection-only" + disabled$="[[disabled]]" on-change="onSelectionOnlyChange_" checked$="[[settings.selectionOnly.value]]"> <span>$i18n{optionSelectionOnly}</span> diff --git a/chromium/chrome/browser/resources/print_preview/new/other_options_settings.js b/chromium/chrome/browser/resources/print_preview/new/other_options_settings.js index 07e21f5de60..07d9f82eb58 100644 --- a/chromium/chrome/browser/resources/print_preview/new/other_options_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/other_options_settings.js @@ -7,6 +7,10 @@ Polymer({ behaviors: [SettingsBehavior], + properties: { + disabled: Boolean, + }, + observers: [ 'onHeaderFooterSettingChange_(settings.headerFooter.value)', 'onDuplexSettingChange_(settings.duplex.value)', diff --git a/chromium/chrome/browser/resources/print_preview/new/pages_settings.html b/chromium/chrome/browser/resources/print_preview/new/pages_settings.html index 72fc32d2160..2777b3b0973 100644 --- a/chromium/chrome/browser/resources/print_preview/new/pages_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/pages_settings.html @@ -1,6 +1,5 @@ <link rel="import" href="chrome://resources/html/polymer.html"> -<link rel="import" href="chrome://resources/html/cr.html"> <link rel="import" href="checkbox_radio_css.html"> <link rel="import" href="../data/document_info.html"> <link rel="import" href="input_css.html"> @@ -30,16 +29,19 @@ <div slot="controls"> <div class="radio"> <label><input type="radio" name="pages" id="all-radio-button" - checked="{{allSelected_::change}}"> + checked="{{allSelected_::change}}" + disabled$="[[getDisabled_(disabled, settings.pages.valid)]]"> <span>$i18n{optionAllPages}</span> </label> <label class="custom-input-wrapper" for="page-settings-custom-input" tabindex=-1> <input type="radio" name="pages" id="custom-radio-button" + disabled$="[[getDisabled_(disabled, settings.pages.valid)]]" on-click="onCustomRadioClick_"> <input class="user-value" type="text" value="{{inputString_::input}}" id="page-settings-custom-input" checked="{{customSelected_::change}}" + disabled$="[[getDisabled_(dsiabled, settings.pages.valid)]]" pattern="([0-9]*(-)?[0-9]*(,)( )?)*([0-9]*(-)?[0-9]*(,)?( )?)?" on-focus="onCustomInputFocus_" on-blur="onCustomInputBlur_" placeholder="$i18n{examplePageRangeText}" diff --git a/chromium/chrome/browser/resources/print_preview/new/pages_settings.js b/chromium/chrome/browser/resources/print_preview/new/pages_settings.js index e230e2c3500..4b6e5276fc1 100644 --- a/chromium/chrome/browser/resources/print_preview/new/pages_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/pages_settings.js @@ -2,7 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -cr.exportPath('print_preview_new'); +(function() { +'use strict'; /** @enum {number} */ const PagesInputErrorState = { @@ -44,6 +45,14 @@ Polymer({ value: false, }, + disabled: Boolean, + + /** @private {number} */ + errorState_: { + type: Number, + value: PagesInputErrorState.NO_ERROR, + }, + /** @private {!Array<number>} */ pagesToPrint_: { type: Array, @@ -56,13 +65,6 @@ Polymer({ type: Array, computed: 'computeRangesToPrint_(pagesToPrint_, allPagesArray_)', }, - - /** @private {!PagesInputErrorState} */ - errorState_: { - type: Number, - computed: 'computeErrorState_(documentInfo.pageCount, pagesToPrint_)', - }, - }, observers: [ @@ -71,6 +73,14 @@ Polymer({ ], /** + * @return {boolean} Whether the controls should be disabled. + * @private + */ + getDisabled_: function() { + return this.getSetting('pages').valid && this.disabled; + }, + + /** * @return {!Array<number>} * @private */ @@ -88,10 +98,14 @@ Polymer({ * @private */ computePagesToPrint_: function() { - if (this.allSelected_ || this.inputString_.trim() == '') + if (this.allSelected_ || this.inputString_.trim() == '') { + this.errorState_ = PagesInputErrorState.NO_ERROR; return this.allPagesArray_; - if (!this.$$('.user-value').validity.valid) - return []; + } + if (!this.$$('.user-value').validity.valid) { + this.errorState_ = PagesInputErrorState.INVALID_SYNTAX; + return this.pagesToPrint_; + } const pages = []; const added = {}; @@ -103,11 +117,15 @@ Polymer({ continue; const limits = range.split('-'); let min = parseInt(limits[0], 10); - if (min < 1) - return []; + if (min < 1) { + this.errorState_ = PagesInputErrorState.OUT_OF_BOUNDS; + return this.pagesToPrint_; + } if (limits.length == 1) { - if (min > maxPage) - return [-1]; + if (min > maxPage) { + this.errorState_ = PagesInputErrorState.OUT_OF_BOUNDS; + return this.pagesToPrint_; + } if (!added.hasOwnProperty(min)) { pages.push(min); added[min] = true; @@ -120,10 +138,14 @@ Polymer({ min = 1; if (isNaN(max)) max = maxPage; - if (min > max) - return []; - if (max > maxPage) - return [-1]; + if (min > max) { + this.errorState_ = PagesInputErrorState.INVALID_SYNTAX; + return this.pagesToPrint_; + } + if (max > maxPage) { + this.errorState_ = PagesInputErrorState.OUT_OF_BOUNDS; + return this.pagesToPrint_; + } for (let i = min; i <= max; i++) { if (!added.hasOwnProperty(i)) { pages.push(i); @@ -162,17 +184,17 @@ Polymer({ }, /** - * @return {!PagesInputErrorState} - * @private + * @return {boolean} Whether pages setting and pagesToPrint_ match. */ - computeErrorState_: function() { - if (this.documentInfo.pageCount == 0) // page count not yet initialized - return PagesInputErrorState.NO_ERROR; - if (this.pagesToPrint_.length == 0) - return PagesInputErrorState.INVALID_SYNTAX; - if (this.pagesToPrint_[0] == -1) - return PagesInputErrorState.OUT_OF_BOUNDS; - return PagesInputErrorState.NO_ERROR; + settingMatches_: function() { + const setting = this.getSetting('pages').value; + if (setting.length != this.pagesToPrint_.length) + return false; + for (let index = 0; index < this.pagesToPrint_.length; index++) { + if (this.pagesToPrint_[index] != setting[index]) + return false; + } + return true; }, /** @@ -188,8 +210,10 @@ Polymer({ } this.$$('.user-value').classList.remove('invalid'); this.setSettingValid('pages', true); - this.setSetting('pages', this.pagesToPrint_); - this.setSetting('ranges', this.rangesToPrint_); + if (!this.settingMatches_()) { + this.setSetting('pages', this.pagesToPrint_); + this.setSetting('ranges', this.rangesToPrint_); + } }, /** @private */ @@ -249,3 +273,4 @@ Polymer({ return this.errorState_ == PagesInputErrorState.NO_ERROR; } }); +})(); diff --git a/chromium/chrome/browser/resources/print_preview/new/preview_area.html b/chromium/chrome/browser/resources/print_preview/new/preview_area.html index 716d3b5e6f3..a7a9d5cd05b 100644 --- a/chromium/chrome/browser/resources/print_preview/new/preview_area.html +++ b/chromium/chrome/browser/resources/print_preview/new/preview_area.html @@ -1,6 +1,7 @@ <link rel="import" href="chrome://resources/html/polymer.html"> <link rel="import" href="chrome://resources/html/cr.html"> +<link rel="import" href="chrome://resources/html/i18n_behavior.html"> <link rel="import" href="chrome://resources/html/web_ui_listener_behavior.html"> <link rel="import" href="../native_layer.html"> <link rel="import" href="../print_preview_utils.html"> @@ -10,6 +11,8 @@ <link rel="import" href="../data/margins.html"> <link rel="import" href="../data/printable_area.html"> <link rel="import" href="../data/size.html"> +<link rel="import" href="model.html"> +<link rel="import" href="state.html"> <link rel="import" href="settings_behavior.html"> <dom-module id="print-preview-preview-area"> @@ -108,37 +111,20 @@ } </style> - <div class="preview-area-overlay-layer"> + <div class$="preview-area-overlay-layer [[getInvisible_(previewState_)]]" + aria-hidden$="[[previewLoaded(previewState_)]]"> <div class="preview-area-messages"> - - <div class="preview-area-loading-message preview-area-message" - hidden$="[[!state.previewLoading]]"> - <span>$i18n{loading}</span> - <span class="preview-area-loading-message-jumping-dots jumping-dots" - ><span>.</span><span>.</span><span>.</span></span> - </div> - - <div class="preview-area-custom-message preview-area-message" hidden> - <div class="preview-area-custom-message-text"></div> - <div class="preview-area-custom-action-area"> - <button class="preview-area-open-system-dialog-button"> - $i18n{launchNativeDialog} - </button> - <div - class="preview-area-open-system-dialog-button-throbber throbber" - hidden></div> + <div class="preview-area-message"> + <div> + <span>[[currentMessage_(previewState_)]]</span> + <span class$="preview-area-loading-message-jumping-dots + [[getJumpingDots_(previewState_)]]" + hidden$="[[!isPreviewLoading_(previewState_)]]"> + <span>.</span><span>.</span><span>.</span> + </span> </div> - </div> - - <div class="preview-area-preview-failed-message preview-area-message" - hidden$="[[!state.previewFailed]]"> - $i18n{previewFailed} - </div> - - <div class="preview-area-print-failed preview-area-message" - hidden$="[[!state.invalidSettings]]"> - <div>$i18n{invalidPrinterSettings}</div> - <div class="preview-area-print-failed-action-area"> + <div class="preview-area-action-area" + hidden$="[[!displaySystemDialogButton_(previewState_)]]"> <button class="preview-area-open-system-dialog-button"> $i18n{launchNativeDialog} </button> @@ -147,7 +133,6 @@ hidden></div> </div> </div> - </div> </div> <div class="preview-area-plugin-wrapper"> diff --git a/chromium/chrome/browser/resources/print_preview/new/preview_area.js b/chromium/chrome/browser/resources/print_preview/new/preview_area.js index 6a1b53b0d1a..9c456b76ccf 100644 --- a/chromium/chrome/browser/resources/print_preview/new/preview_area.js +++ b/chromium/chrome/browser/resources/print_preview/new/preview_area.js @@ -34,20 +34,23 @@ cr.exportPath('print_preview_new'); */ print_preview_new.PDFPlugin; -/** - * Constant values matching printing::DuplexMode enum. - * @enum {number} - */ -print_preview_new.DuplexMode = { - SIMPLEX: 0, - LONG_EDGE: 1, - UNKNOWN_DUPLEX_MODE: -1 +(function() { +'use strict'; + +/** @enum {string} */ +const PreviewAreaState_ = { + LOADING: 'loading', + DISPLAY_PREVIEW: 'display-preview', + OPEN_IN_PREVIEW: 'open-in-preview', + INVALID_SETTINGS: 'invalid-settings', + PREVIEW_FAILED: 'preview-failed', }; Polymer({ is: 'print-preview-preview-area', - behaviors: [WebUIListenerBehavior, SettingsBehavior], + behaviors: [WebUIListenerBehavior, SettingsBehavior, I18nBehavior], + properties: { /** @type {print_preview.DocumentInfo} */ documentInfo: Object, @@ -55,10 +58,17 @@ Polymer({ /** @type {print_preview.Destination} */ destination: Object, - /** @type {print_preview_new.State} */ + /** @type {!print_preview_new.State} */ state: { - type: Object, + type: Number, + observer: 'onStateChanged_', + }, + + /** @private {string} */ + previewState_: { + type: String, notify: true, + value: PreviewAreaState_.LOADING, }, }, @@ -68,9 +78,7 @@ Polymer({ 'settings.layout.value, settings.margins.value, ' + 'settings.mediaSize.value, settings.ranges.value,' + 'settings.selectionOnly.value, settings.scaling.value, ' + - 'destination.id, destination.capabilities, state.initialized)', - 'onPreviewStateChanged_(state.previewLoading, state.invalidSettings, ' + - 'state.previewFailed)', + 'settings.rasterize.value, destination.id, destination.capabilities)', ], /** @private {print_preview.NativeLayer} */ @@ -79,13 +87,16 @@ Polymer({ /** @private {number} */ inFlightRequestId_: -1, + /** @private {boolean} */ + requestPreviewWhenReady_: false, + /** @private {HTMLEmbedElement|print_preview_new.PDFPlugin} */ plugin_: null, - /** @private {boolean} */ + /** @private {boolean} Whether the plugin is loaded */ pluginLoaded_: false, - /** @private {boolean} */ + /** @private {boolean} Whether the document is ready */ documentReady_: false, /** @override */ @@ -102,59 +113,119 @@ Polymer({ this.$$('.preview-area-compatibility-object-out-of-process'); const isOOPCompatible = oopCompatObj.postMessage; oopCompatObj.parentElement.removeChild(oopCompatObj); - if (!isOOPCompatible) - this.set('state.previewFailed', true); - else - this.set('state.previewLoading', true); + if (!isOOPCompatible) { + this.previewState_ = PreviewAreaState_.PREVIEW_FAILED; + this.fire('preview-failed'); + } + }, + + /** @return {boolean} Whether the preview is loaded. */ + previewLoaded: function() { + return this.previewState_ == PreviewAreaState_.DISPLAY_PREVIEW; }, /** @private */ onSettingsChanged_: function() { - if (!this.state.initialized || !this.getSetting('scaling').valid || - !this.getSetting('pages').valid || !this.getSetting('copies').valid || - !this.destination || !this.destination.capabilities) { + if (this.state == print_preview_new.State.READY) { + this.startPreview_(); return; } + this.requestPreviewWhenReady_ = true; + }, + + /** + * @return {string} 'invisible' if overlay is invisible, '' otherwise. + * @private + */ + getInvisible_: function() { + return this.previewLoaded() ? 'invisible' : ''; + }, + + /** + * @return {boolean} Whether the preview is currently loading. + * @private + */ + isPreviewLoading_: function() { + return this.previewState_ == PreviewAreaState_.LOADING; + }, + + /** + * @return {string} 'jumping-dots' to enable animation, '' otherwise. + * @private + */ + getJumpingDots_: function() { + return this.isPreviewLoading_() ? 'jumping-dots' : ''; + }, + + /** + * @return {boolean} Whether the system dialog button should be shown. + * @private + */ + displaySystemDialogButton_: function() { + return this.previewState_ == PreviewAreaState_.INVALID_SETTINGS || + this.previewState_ == PreviewAreaState_.OPEN_IN_PREVIEW; + }, + + /** + * @return {string} The current preview area message to display. + * @private + */ + currentMessage_: function() { + if (this.previewState_ == PreviewAreaState_.LOADING) + return this.i18n('loading'); + if (this.previewState_ == PreviewAreaState_.OPEN_IN_PREVIEW) + return this.i18n('openPdfInPreview'); + if (this.previewState_ == PreviewAreaState_.INVALID_SETTINGS) + return this.i18n('invalidSettings'); + if (this.previewState_ == PreviewAreaState_.PREVIEW_FAILED) + return this.i18n('previewFailed'); + return ''; + }, + + /** @private */ + startPreview_: function() { + this.previewState_ = PreviewAreaState_.LOADING; this.documentReady_ = false; - this.set('state.previewLoading', true); this.getPreview_().then( previewUid => { if (!this.documentInfo.isModifiable) this.onPreviewStart_(previewUid, -1); this.documentReady_ = true; - if (this.pluginLoaded_) - this.set('state.previewLoading', false); + if (this.pluginLoaded_) { + this.previewState_ = PreviewAreaState_.DISPLAY_PREVIEW; + this.fire('preview-loaded'); + } }, type => { if (/** @type{string} */ (type) == 'SETTINGS_INVALID') - this.set('state.invalidSettings', true); - else if (/** @type{string} */ (type) != 'CANCELLED') - this.set('state.previewFailed', true); - this.set('state.previewLoading', false); + this.previewState_ = PreviewAreaState_.INVALID_SETTINGS; + else if (/** @type{string} */ (type) != 'CANCELLED') { + this.previewState_ = PreviewAreaState_.PREVIEW_FAILED; + this.fire('preview-failed'); + } }); }, - /** - * Set the visibility of the message overlay. - * @param {boolean} visible Whether to make the overlay visible or not - * @private - */ - setOverlayVisible_: function(visible) { - const overlayEl = this.$$('.preview-area-overlay-layer'); - overlayEl.classList.toggle('invisible', !visible); - overlayEl.setAttribute('aria-hidden', !visible); - }, - /** @private */ - onPreviewStateChanged_: function() { - // update the appearance here. - const visible = this.state.previewLoading || this.state.previewFailed || - this.state.invalidSettings; - this.setOverlayVisible_(visible); - - // Disable jumping animation to conserve cycles. - const jumpingDotsEl = this.$$('.preview-area-loading-message-jumping-dots'); - jumpingDotsEl.classList.toggle('jumping-dots', this.state.previewLoading); + onStateChanged_: function() { + switch (this.state) { + case (print_preview_new.State.NOT_READY): + // Resetting the destination clears the invalid settings error. + this.previewState_ = PreviewAreaState_.LOADING; + break; + case (print_preview_new.State.READY): + // Request a new preview. + if (this.requestPreviewWhenReady_) { + this.startPreview_(); + this.requestPreviewWhenReady_ = false; + } + break; + case (print_preview_new.State.INVALID_PRINTER): + this.previewState_ = PreviewAreaState_.INVALID_SETTINGS; + break; + default: + break; + } }, /** @@ -240,8 +311,10 @@ Polymer({ */ onPluginLoad_: function() { this.pluginLoaded_ = true; - if (this.documentReady_) - this.set('state.previewLoading', false); + if (this.documentReady_) { + this.previewState_ = PreviewAreaState_.DISPLAY_PREVIEW; + this.fire('preview-loaded'); + } }, /** @@ -345,7 +418,7 @@ Polymer({ printWithCloudPrint: !this.destination.isLocal, printWithPrivet: this.destination.isPrivet, printWithExtension: this.destination.isExtension, - rasterizePDF: false, + rasterizePDF: this.getSettingValue('rasterize'), }; // Set 'cloudPrintID' only if the this.destination is not local. @@ -372,3 +445,4 @@ Polymer({ return this.nativeLayer_.getPreview(JSON.stringify(ticket), pageCount); }, }); +})(); diff --git a/chromium/chrome/browser/resources/print_preview/new/print_preview_search_box.html b/chromium/chrome/browser/resources/print_preview/new/print_preview_search_box.html new file mode 100644 index 00000000000..50e421bb32b --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/print_preview_search_box.html @@ -0,0 +1,41 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/cr_search_field/cr_search_field_behavior.html"> +<link rel="import" href="print_preview_shared_css.html"> + +<dom-module id="print-preview-search-box"> + <template> + <style include="print-preview-shared"> + :host { + display: flex; + position: relative; + user-select: none; + } + + .search-box-icon { + display: inline-block; + height: 1em; + left: 8px; + position: absolute; + right: 8px; + top: 6px; + user-select: none; + width: 1em; + } + + .search-box-input { + text-indent: 2em; + width: 100%; + } + + .search-box-input::-webkit-search-cancel-button { + -webkit-appearance: none; + } + </style> + <img src="../images/search.png" class="search-box-icon" alt=""> + <input type="search" id="searchInput" class="search-box-input" + on-search="onSearchTermSearch" on-input="onSearchTermInput" + incremental aria-label$="[[label]]" placeholder="[[label]]"> + </template> + <script src="print_preview_search_box.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/print_preview_search_box.js b/chromium/chrome/browser/resources/print_preview/new/print_preview_search_box.js new file mode 100644 index 00000000000..c8890137541 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/print_preview_search_box.js @@ -0,0 +1,42 @@ +// Copyright 2018 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. + +(function() { +'use strict'; + +/** @type {!RegExp} */ +const SANITIZE_REGEX = /[-[\]{}()*+?.,\\^$|#\s]/g; + +Polymer({ + is: 'print-preview-search-box', + + behaviors: [CrSearchFieldBehavior], + + properties: { + /** @type {?RegExp} */ + searchQuery: { + type: Object, + notify: true, + }, + }, + + listeners: { + 'search-changed': 'onSearchChanged_', + }, + + /** @return {!HTMLInputElement} */ + getSearchInput: function() { + return this.$.searchInput; + }, + + /** + * @param {!CustomEvent} e Event containing the new search. + */ + onSearchChanged_: function(e) { + const safeQuery = e.detail.trim().replace(SANITIZE_REGEX, '\\$&'); + this.searchQuery = + safeQuery.length > 0 ? new RegExp(`(${safeQuery})`, 'i') : null; + }, +}); +})(); diff --git a/chromium/chrome/browser/resources/print_preview/new/scaling_settings.html b/chromium/chrome/browser/resources/print_preview/new/scaling_settings.html index fbb0f21462f..6fc479afebf 100644 --- a/chromium/chrome/browser/resources/print_preview/new/scaling_settings.html +++ b/chromium/chrome/browser/resources/print_preview/new/scaling_settings.html @@ -12,12 +12,14 @@ </style> <print-preview-number-settings-section max-value=200 min-value=10 default-value="100" input-label="$i18n{scalingLabel}" - input-string="{{inputString_}}" input-valid="{{inputValid_}}" - hint-message="$i18n{scalingInstruction}" class="multirow-controls"> + disabled="[[disabled]]" current-value="{{currentValue_}}" + input-valid="{{inputValid_}}" hint-message="$i18n{scalingInstruction}" + class="multirow-controls"> <div slot="opt-outside-content" class="checkbox" hidden$="[[!settings.fitToPage.available]]"> <label aria-live="polite"> <input type="checkbox" id="fit-to-page-checkbox" + disabled$="[[getDisabled_(disabled, inputValid_)]]" on-change="onFitToPageChange_"> <span>$i18n{optionFitToPage}</span> </label> diff --git a/chromium/chrome/browser/resources/print_preview/new/scaling_settings.js b/chromium/chrome/browser/resources/print_preview/new/scaling_settings.js index 8117771923e..4588514c6a8 100644 --- a/chromium/chrome/browser/resources/print_preview/new/scaling_settings.js +++ b/chromium/chrome/browser/resources/print_preview/new/scaling_settings.js @@ -12,10 +12,12 @@ Polymer({ documentInfo: Object, /** @private {string} */ - inputString_: String, + currentValue_: String, /** @private {boolean} */ inputValid_: Boolean, + + disabled: Boolean, }, /** @private {string} */ @@ -27,7 +29,7 @@ Polymer({ observers: [ 'onFitToPageSettingChange_(settings.fitToPage.value, ' + 'settings.fitToPage.available, documentInfo.fitToPageScaling)', - 'onInputChanged_(inputString_, inputValid_)', + 'onInputChanged_(currentValue_, inputValid_)', 'onScalingSettingChanged_(settings.scaling.value)', ], @@ -39,13 +41,13 @@ Polymer({ this.$$('#fit-to-page-checkbox').checked = fitToPage.value; if (!fitToPage.value) { // Fit to page is no longer checked. Update the display. - this.inputString_ = this.lastValidScaling_; + this.currentValue_ = this.lastValidScaling_; } else if (fitToPage.value) { // Set flag to number of expected calls to onInputChanged_. If scaling - // is valid, 1 call will occur due to the change to |inputString_|. If + // is valid, 1 call will occur due to the change to |currentValue_|. If // not, 2 calls will occur, since |inputValid_| will also change. this.fitToPageFlag_ = this.inputValid_ ? 1 : 2; - this.inputString_ = this.documentInfo.fitToPageScaling; + this.currentValue_ = this.documentInfo.fitToPageScaling; } }, @@ -57,7 +59,7 @@ Polymer({ // Update last valid scaling and ensure input string matches. this.lastValidScaling_ = /** @type {string} */ (this.getSetting('scaling').value); - this.inputString_ = this.lastValidScaling_; + this.currentValue_ = this.lastValidScaling_; }, /** @@ -70,8 +72,9 @@ Polymer({ if (fitToPage && this.fitToPageFlag_ == 0) { // User modified scaling while fit to page was checked. Uncheck fit to // page. - if (this.inputValid_) - this.setSetting('scaling', this.inputString_); + const wasValid = this.getSetting('scaling').valid; + if (this.inputValid_ && wasValid) + this.setSetting('scaling', this.currentValue_); else this.setSettingValid('scaling', false); this.$$('#fit-to-page-checkbox').checked = false; @@ -83,9 +86,10 @@ Polymer({ } else { // User modified scaling while fit to page was not checked or // scaling setting was set. + const wasValid = this.getSetting('scaling').valid; this.setSettingValid('scaling', this.inputValid_); - if (this.inputValid_) - this.setSetting('scaling', this.inputString_); + if (this.inputValid_ && wasValid) + this.setSetting('scaling', this.currentValue_); } }, @@ -93,4 +97,12 @@ Polymer({ onFitToPageChange_: function() { this.setSetting('fitToPage', this.$$('#fit-to-page-checkbox').checked); }, + + /** + * @return {boolean} Whether the input should be disabled. + * @private + */ + getDisabled_: function() { + return this.disabled && this.inputValid_; + }, }); diff --git a/chromium/chrome/browser/resources/print_preview/new/search_dialog_css.html b/chromium/chrome/browser/resources/print_preview/new/search_dialog_css.html new file mode 100644 index 00000000000..6ec25973375 --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/search_dialog_css.html @@ -0,0 +1,48 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/cr_dialog/cr_dialog.html"> +<link rel="import" href="button_css.html"> +<link rel="import" href="print_preview_search_box.html"> +<link rel="import" href="print_preview_shared_css.html"> + +<dom-module id="search-dialog"> + <template> + <style include="print-preview-shared button"> + #dialog::backdrop { + background-color: rgba(255, 255, 255, 0.75); + } + + #dialog { + --cr-dialog-close-image: { + background-image: url(chrome://theme/IDR_CLOSE_DIALOG); + } + --cr-dialog-close-image-active: { + background-image: url(chrome://theme/IDR_CLOSE_DIALOG_P); + } + --cr-dialog-close-image-hover: { + background-image: url(chrome://theme/IDR_CLOSE_DIALOG_H); + } + --cr-icon-ripple-size: 0; + --cr-icon-size: 14px; + --cr-dialog-body: { + box-sizing: border-box; + padding-top: 0; + } + --cr-dialog-wrapper: { + max-height: calc(100vh - 40px); + } + box-shadow: 0 4px 23px 5px rgba(0, 0, 0, 0.2), + 0 2px 6px rgba(0, 0, 0, 0.15); + } + + #searchBox { + font-size: calc(13/15 * 1em); + margin-top: 14px; + } + + #body { + height: 100vh; + } + </style> + </template> +</dom-module> diff --git a/chromium/chrome/browser/resources/print_preview/new/select_css.html b/chromium/chrome/browser/resources/print_preview/new/select_css.html index 5a316158da1..04838c08b04 100644 --- a/chromium/chrome/browser/resources/print_preview/new/select_css.html +++ b/chromium/chrome/browser/resources/print_preview/new/select_css.html @@ -21,14 +21,14 @@ select:enabled:hover { background-image: url(chrome://resources/images/select.png), linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0); - @apply(--print-preview-hover); + @apply --print-preview-hover; } </if> select:enabled:active { background-image: url(chrome://resources/images/select.png), linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7); - @apply(--print-preview-active); + @apply --print-preview-active; } select:disabled { diff --git a/chromium/chrome/browser/resources/print_preview/new/settings_behavior.js b/chromium/chrome/browser/resources/print_preview/new/settings_behavior.js index aa4c95dc575..b28e5fcd8ae 100644 --- a/chromium/chrome/browser/resources/print_preview/new/settings_behavior.js +++ b/chromium/chrome/browser/resources/print_preview/new/settings_behavior.js @@ -91,6 +91,8 @@ const SettingsBehavior = { // is no way for the user to change the value in this case. if (!valid) assert(setting.available, 'Setting is not available: ' + settingName); + if (valid != setting.valid) + this.fire('setting-valid-changed', valid); this.set(`settings.${settingName}.valid`, valid); } }; diff --git a/chromium/chrome/browser/resources/print_preview/new/settings_select.html b/chromium/chrome/browser/resources/print_preview/new/settings_select.html index 0525c00e2b5..9b867bec3b0 100644 --- a/chromium/chrome/browser/resources/print_preview/new/settings_select.html +++ b/chromium/chrome/browser/resources/print_preview/new/settings_select.html @@ -9,9 +9,10 @@ <template> <style include="print-preview-shared select"> </style> - <select on-change="onChange_"> + <select on-change="onChange_" disabled$="[[disabled]]"> <template is="dom-repeat" items="[[capability.option]]"> - <option selected="[[item.is_default]]" value="[[getValue_(item)]]"> + <option selected="[[isSelected_(item, selectedValue_)]]" + value="[[getValue_(item)]]"> [[getDisplayName_(item)]] </option> </template> diff --git a/chromium/chrome/browser/resources/print_preview/new/settings_select.js b/chromium/chrome/browser/resources/print_preview/new/settings_select.js index aba0680710a..4877620f573 100644 --- a/chromium/chrome/browser/resources/print_preview/new/settings_select.js +++ b/chromium/chrome/browser/resources/print_preview/new/settings_select.js @@ -23,13 +23,31 @@ Polymer({ /** @type {{ option: Array<!print_preview_new.SelectOption> }} */ capability: Object, - /** @type {string} */ settingName: String, + + disabled: Boolean, + + /** @private {string} */ + selectedValue_: { + type: String, + notify: true, + value: '', + }, + }, + + /** + * @param {!print_preview_new.SelectOption} option Option to check. + * @return {boolean} Whether the option is selected. + * @private + */ + isSelected_: function(option) { + return this.getValue_(option) == this.selectedValue_ || + (!!option.is_default && this.selectedValue_ == ''); }, /** @param {string} value The value to select. */ selectValue: function(value) { - this.$$('select').value = value; + this.selectedValue_ = value; }, /** diff --git a/chromium/chrome/browser/resources/print_preview/new/state.html b/chromium/chrome/browser/resources/print_preview/new/state.html new file mode 100644 index 00000000000..12da96dc34d --- /dev/null +++ b/chromium/chrome/browser/resources/print_preview/new/state.html @@ -0,0 +1,5 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/html/cr.html"> + +<script src="state.js"></script> diff --git a/chromium/chrome/browser/resources/print_preview/new/state.js b/chromium/chrome/browser/resources/print_preview/new/state.js index 99cb889343e..26d1a636918 100644 --- a/chromium/chrome/browser/resources/print_preview/new/state.js +++ b/chromium/chrome/browser/resources/print_preview/new/state.js @@ -1,18 +1,68 @@ -// Copyright 2017 The Chromium Authors. All rights reserved. +// Copyright 2018 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. cr.exportPath('print_preview_new'); -/** - * @typedef {{ - * previewLoading: boolean, - * previewFailed: boolean, - * cloudPrintError: string, - * privetExtensionError: string, - * invalidSettings: boolean, - * initialized: boolean, - * cancelled: boolean, - * }} - */ -print_preview_new.State; +/** @enum {number} */ +print_preview_new.State = { + NOT_READY: 0, + READY: 1, + HIDDEN: 2, + PRINTING: 3, + INVALID_TICKET: 4, + INVALID_PRINTER: 5, + FATAL_ERROR: 6, + CLOSING: 7, +}; + +Polymer({ + is: 'print-preview-state', + + properties: { + /** @type {print_preview_new.State} */ + state: { + type: Number, + notify: true, + value: print_preview_new.State.NOT_READY, + }, + }, + + /** @param {print_preview_new.State} newState The state to transition to. */ + transitTo: function(newState) { + switch (newState) { + case (print_preview_new.State.NOT_READY): + assert( + this.state == print_preview_new.State.NOT_READY || + this.state == print_preview_new.State.READY || + this.state == print_preview_new.State.INVALID_PRINTER); + break; + case (print_preview_new.State.READY): + assert( + this.state == print_preview_new.State.INVALID_TICKET || + this.state == print_preview_new.State.NOT_READY || + this.state == print_preview_new.State.PRINTING); + break; + case (print_preview_new.State.HIDDEN): + assert(this.state == print_preview_new.State.READY); + break; + case (print_preview_new.State.PRINTING): + assert( + this.state == print_preview_new.State.READY || + this.state == print_preview_new.State.HIDDEN); + break; + case (print_preview_new.State.INVALID_TICKET): + assert(this.state == print_preview_new.State.READY); + break; + case (print_preview_new.State.INVALID_PRINTER): + assert( + this.state == print_preview_new.State.NOT_READY || + this.state == print_preview_new.State.READY); + break; + case (print_preview_new.State.CLOSING): + assert(this.state != print_preview_new.State.HIDDEN); + break; + } + this.state = newState; + }, +}); diff --git a/chromium/chrome/browser/resources/print_preview/preview_generator.js b/chromium/chrome/browser/resources/print_preview/preview_generator.js index 4cfb3d90ede..832529cca5f 100644 --- a/chromium/chrome/browser/resources/print_preview/preview_generator.js +++ b/chromium/chrome/browser/resources/print_preview/preview_generator.js @@ -101,6 +101,12 @@ cr.define('print_preview', function() { this.scalingValue_ = 100; /** + * Whether the PDF should be rasterized for printing. + * @private {boolean} + */ + this.rasterize_ = false; + + /** * Page ranges setting used used to generate the last preview. * @private {Array<{from: number, to: number}>} */ @@ -176,6 +182,7 @@ cr.define('print_preview', function() { this.printTicketStore_.headerFooter.getValue(); this.colorValue_ = this.printTicketStore_.color.getValue(); this.isFitToPageEnabled_ = this.printTicketStore_.fitToPage.getValue(); + this.rasterize_ = this.printTicketStore_.rasterize.getValue(); this.scalingValue_ = this.printTicketStore_.scaling.getValueAsNumber(); this.pageRanges_ = this.printTicketStore_.pageRange.getPageRanges(); this.marginsType_ = this.printTicketStore_.marginsType.getValue(); @@ -240,7 +247,7 @@ cr.define('print_preview', function() { printWithCloudPrint: !destination.isLocal, printWithPrivet: destination.isPrivet, printWithExtension: destination.isExtension, - rasterizePDF: false, + rasterizePDF: printTicketStore.rasterize.getValue(), shouldPrintBackgrounds: printTicketStore.cssBackground.getValue(), shouldPrintSelectionOnly: printTicketStore.selectionOnly.getValue() }; @@ -321,6 +328,7 @@ cr.define('print_preview', function() { !ticketStore.color.isValueEqual(this.colorValue_) || !ticketStore.scaling.isValueEqual(this.scalingValue_) || !ticketStore.fitToPage.isValueEqual(this.isFitToPageEnabled_) || + !ticketStore.rasterize.isValueEqual(this.rasterize_) || (!ticketStore.marginsType.isValueEqual(this.marginsType_) && !ticketStore.marginsType.isValueEqual( print_preview.ticket_items.MarginsTypeValue.CUSTOM)) || diff --git a/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.css b/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.css index a6ce9ed4fae..fd272fa1824 100644 --- a/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.css +++ b/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.css @@ -80,7 +80,6 @@ } #preview-area .learn-more-link { - -webkit-margin-start: 0.5em; color: rgb(51, 103, 214); } diff --git a/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.js b/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.js index d1cb9dedd2d..89ceb0d1654 100644 --- a/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.js +++ b/chromium/chrome/browser/resources/print_preview/previewarea/preview_area.js @@ -81,7 +81,8 @@ cr.define('print_preview', function() { this.printTicketStore_ = printTicketStore; /** - * Used to contruct the preview generator. + * Used to construct the preview generator and to open the GCP learn more + * help link. * @type {!print_preview.NativeLayer} * @private */ @@ -149,6 +150,13 @@ cr.define('print_preview', function() { this.isDocumentReady_ = false; /** + * Whether the current destination is valid. + * @type {boolean} + * @private + */ + this.isDestinationValid_ = true; + + /** * Timeout object used to display a loading message if the preview is taking * a long time to generate. * @type {?number} @@ -213,7 +221,8 @@ cr.define('print_preview', function() { 'preview-area-open-system-dialog-button-throbber', OVERLAY: 'preview-area-overlay-layer', MARGIN_CONTROL: 'margin-control', - PREVIEW_AREA: 'preview-area-plugin-wrapper' + PREVIEW_AREA: 'preview-area-plugin-wrapper', + GCP_ERROR_LEARN_MORE_LINK: 'learn-more-link' }; /** @@ -321,6 +330,16 @@ cr.define('print_preview', function() { this.showMessage_(print_preview.PreviewAreaMessageId_.CUSTOM, message); }, + /** @param {boolean} valid Whether the current destination is valid. */ + setDestinationValid: function(valid) { + this.isDestinationValid_ = valid; + // If destination is valid and preview is ready, hide the error message. + if (valid && this.isPluginReloaded_ && this.isDocumentReady_) { + this.setOverlayVisible_(false); + this.dispatchPreviewGenerationDoneIfReady_(); + } + }, + /** @override */ enterDocument: function() { print_preview.Component.prototype.enterDocument.call(this); @@ -328,6 +347,9 @@ cr.define('print_preview', function() { this.tracker.add( assert(this.openSystemDialogButton_), 'click', this.onOpenSystemDialogButtonClick_.bind(this)); + this.tracker.add( + assert(this.gcpErrorLearnMoreLink_), 'click', + this.onGcpErrorLearnMoreClick_.bind(this)); const TicketStoreEvent = print_preview.PrintTicketStore.EventType; [TicketStoreEvent.INITIALIZE, TicketStoreEvent.CAPABILITIES_CHANGE, @@ -377,6 +399,7 @@ cr.define('print_preview', function() { print_preview.Component.prototype.exitDocument.call(this); this.overlayEl_ = null; this.openSystemDialogButton_ = null; + this.gcpErrorLearnMoreLink_ = null; }, /** @override */ @@ -386,6 +409,8 @@ cr.define('print_preview', function() { PreviewArea.Classes_.OVERLAY)[0]; this.openSystemDialogButton_ = this.getElement().getElementsByClassName( PreviewArea.Classes_.OPEN_SYSTEM_DIALOG_BUTTON)[0]; + this.gcpErrorLearnMoreLink_ = this.getElement().getElementsByClassName( + PreviewArea.Classes_.GCP_ERROR_LEARN_MORE_LINK)[0]; }, /** @@ -526,11 +551,22 @@ cr.define('print_preview', function() { }, /** + * Called when the learn more link for a cloud destination with an invalid + * certificate is clicked. Calls nativeLayer to open a new tab with the help + * page. + * @private + */ + onGcpErrorLearnMoreClick_: function() { + this.nativeLayer_.forceOpenNewTab( + loadTimeData.getString('gcpCertificateErrorLearnMoreURL')); + }, + + /** * Called when the print ticket changes. Updates the preview. * @private */ onTicketChange_: function() { - if (!this.previewGenerator_) + if (!this.previewGenerator_ || !this.isDestinationValid_) return; const previewRequest = this.previewGenerator_.requestPreview(); if (previewRequest.id <= -1) { diff --git a/chromium/chrome/browser/resources/print_preview/print_preview.js b/chromium/chrome/browser/resources/print_preview/print_preview.js index 918f2ad7db4..ce0d488008a 100644 --- a/chromium/chrome/browser/resources/print_preview/print_preview.js +++ b/chromium/chrome/browser/resources/print_preview/print_preview.js @@ -1026,6 +1026,7 @@ cr.define('print_preview', function() { this.uiState_ = PrintPreviewUiState_.ERROR; this.isPreviewGenerationInProgress_ = false; this.printHeader_.isPrintButtonEnabled = false; + this.previewArea_.setDestinationValid(false); }, /** @@ -1311,8 +1312,10 @@ cr.define('print_preview', function() { } // Reset if we had a bad settings fetch since the user selected a new // printer. - if (this.uiState_ == PrintPreviewUiState_.ERROR) + if (this.uiState_ == PrintPreviewUiState_.ERROR) { this.uiState_ = PrintPreviewUiState_.READY; + this.previewArea_.setDestinationValid(true); + } if (this.destinationStore_.selectedDestination && this.isInKioskAutoPrintMode_) { this.onPrintButtonClick_(); diff --git a/chromium/chrome/browser/resources/print_preview/print_preview_new.html b/chromium/chrome/browser/resources/print_preview/print_preview_new.html index 303438ada23..b7a82538e9b 100644 --- a/chromium/chrome/browser/resources/print_preview/print_preview_new.html +++ b/chromium/chrome/browser/resources/print_preview/print_preview_new.html @@ -1,11 +1,14 @@ <!doctype html> <html dir="$i18n{textdirection}" lang="$i18n{language}"> <head> -<meta charset="utf-8"> + <meta charset="utf-8"> <title></title> -</head> -<body> <style> + html { + /* Remove 300ms delay for 'click' event, when using touch interface. */ + touch-action: manipulation; + } + html, body { height: 100%; @@ -14,6 +17,8 @@ width: 100%; } </style> +</head> +<body> <print-preview-app></print-preview-app> <link rel="stylesheet" href="chrome://resources/css/text_defaults.css"> <link rel="import" href="new/app.html"> diff --git a/chromium/chrome/browser/resources/print_preview/print_preview_resources.grd b/chromium/chrome/browser/resources/print_preview/print_preview_resources.grd index 943dce160ce..089ce3ff322 100644 --- a/chromium/chrome/browser/resources/print_preview/print_preview_resources.grd +++ b/chromium/chrome/browser/resources/print_preview/print_preview_resources.grd @@ -29,6 +29,12 @@ <structure name="IDR_PRINT_PREVIEW_NEW_MODEL_JS" file="new/model.js" type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_CLOUD_PRINT_INTERFACE_HTML" + file="cloud_print_interface.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_CLOUD_PRINT_INTERFACE_JS" + file="cloud_print_interface.js" + type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_NATIVE_LAYER_HTML" file="native_layer.html" type="chrome_html" /> @@ -53,12 +59,24 @@ <structure name="IDR_PRINT_PREVIEW_DATA_DESTINATION_STORE_JS" file="data/destination_store.js" type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_DATA_CLOUD_PARSERS_HTML" + file="data/cloud_parsers.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_DATA_CLOUD_PARSERS_JS" + file="data/cloud_parsers.js" + type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_DATA_LOCAL_PARSERS_HTML" file="data/local_parsers.html" type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_DATA_LOCAL_PARSERS_JS" file="data/local_parsers.js" type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_DATA_INVITATION_HTML" + file="data/invitation.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_DATA_INVITATION_JS" + file="data/invitation.js" + type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_DATA_MARGINS_HTML" file="data/margins.html" type="chrome_html" /> @@ -134,6 +152,12 @@ <structure name="IDR_PRINT_PREVIEW_NEW_SETTINGS_BEHAVIOR_JS" file="new/settings_behavior.js" type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_STATE_HTML" + file="new/state.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_STATE_JS" + file="new/state.js" + type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_NEW_SETTINGS_SECTION_HTML" file="new/settings_section.html" type="chrome_html" /> @@ -212,12 +236,57 @@ <structure name="IDR_PRINT_PREVIEW_NEW_ADVANCED_OPTIONS_SETTINGS_JS" file="new/advanced_options_settings.js" type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_ADVANCED_SETTINGS_DIALOG_HTML" + file="new/advanced_settings_dialog.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_ADVANCED_SETTINGS_DIALOG_JS" + file="new/advanced_settings_dialog.js" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_ADVANCED_SETTINGS_ITEM_HTML" + file="new/advanced_settings_item.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_ADVANCED_SETTINGS_ITEM_JS" + file="new/advanced_settings_item.js" + type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_NEW_NUMBER_SETTINGS_SECTION_HTML" file="new/number_settings_section.html" type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_NEW_NUMBER_SETTINGS_SECTION_JS" file="new/number_settings_section.js" type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_DESTINATION_DIALOG_HTML" + file="new/destination_dialog.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_DESTINATION_DIALOG_JS" + file="new/destination_dialog.js" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_DESTINATION_LIST_HTML" + file="new/destination_list.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_DESTINATION_LIST_JS" + file="new/destination_list.js" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_DESTINATION_LIST_ITEM_HTML" + file="new/destination_list_item.html" + type="chrome_html" + preprocess="true" /> + <structure name="IDR_PRINT_PREVIEW_NEW_DESTINATION_LIST_ITEM_JS" + file="new/destination_list_item.js" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_PRINT_PREVIEW_SEARCH_BOX_HTML" + file="new/print_preview_search_box.html" + type="chrome_html" + flattenhtml="true" + allowexternalscript="true" /> + <structure name="IDR_PRINT_PREVIEW_NEW_PRINT_PREVIEW_SEARCH_BOX_JS" + file="new/print_preview_search_box.js" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_HIGHLIGHT_UTILS_HTML" + file="new/highlight_utils.html" + type="chrome_html" /> + <structure name="IDR_PRINT_PREVIEW_NEW_HIGHLIGHT_UTILS_JS" + file="new/highlight_utils.js" + type="chrome_html" /> <structure name="IDR_PRINT_PREVIEW_NEW_PRINT_PREVIEW_SHARED_CSS_HTML" file="new/print_preview_shared_css.html" type="chrome_html" @@ -240,6 +309,9 @@ <structure name="IDR_PRINT_PREVIEW_NEW_THROBBER_CSS_HTML" file="new/throbber_css.html" type="chrome_html"/> + <structure name="IDR_PRINT_PREVIEW_NEW_SEARCH_DIALOG_CSS_HTML" + file="new/search_dialog_css.html" + type="chrome_html"/> <structure name="IDR_PRINT_PREVIEW_NEW_STRINGS_HTML" file="new/strings.html" type="chrome_html" /> diff --git a/chromium/chrome/browser/resources/print_preview/print_preview_utils.js b/chromium/chrome/browser/resources/print_preview/print_preview_utils.js index cd931980ba4..0220810fe82 100644 --- a/chromium/chrome/browser/resources/print_preview/print_preview_utils.js +++ b/chromium/chrome/browser/resources/print_preview/print_preview_utils.js @@ -114,7 +114,7 @@ function pageRangeTextToPageRanges(pageRangeText, opt_totalPageCount) { opt_totalPageCount ? opt_totalPageCount : MAX_PAGE_NUMBER; const regex = /^\s*([0-9]*)\s*-\s*([0-9]*)\s*$/; - const parts = pageRangeText.split(/,/); + const parts = pageRangeText.split(/,|\u3001/); const pageRanges = []; for (let i = 0; i < parts.length; ++i) { diff --git a/chromium/chrome/browser/resources/print_preview/print_preview_utils_unittest.gtestjs b/chromium/chrome/browser/resources/print_preview/print_preview_utils_unittest.gtestjs index ff15d6fa61e..17fa25be71e 100644 --- a/chromium/chrome/browser/resources/print_preview/print_preview_utils_unittest.gtestjs +++ b/chromium/chrome/browser/resources/print_preview/print_preview_utils_unittest.gtestjs @@ -86,6 +86,12 @@ TEST_F('PrintPreviewUtilsUnitTest', 'PageRanges', function() { pageRangeTextToPageRanges("-", null)); assertRangesEqual([[1, 1000000000]], pageRangeTextToPageRanges("-", 0)); + + // https://crbug.com/806165 + assertRangesEqual([1, 2, 3, 1, 56], + pageRangeTextToPageRanges("1\u30012\u30013\u30011\u300156", 100)); + assertRangesEqual([1, 2, 3, 1, 56], + pageRangeTextToPageRanges("1,2,3\u30011\u300156", 100)); }); TEST_F('PrintPreviewUtilsUnitTest', 'InvalidPageRanges', function() { @@ -101,6 +107,11 @@ TEST_F('PrintPreviewUtilsUnitTest', 'InvalidPageRanges', function() { pageRangeTextToPageRanges("1,2,56-40", 100)); assertEquals(PageRangeStatus.LIMIT_ERROR, pageRangeTextToPageRanges("101-110", 100)); + + assertEquals(PageRangeStatus.SYNTAX_ERROR, + pageRangeTextToPageRanges("1\u30012\u30010\u300156", 100)); + assertEquals(PageRangeStatus.SYNTAX_ERROR, + pageRangeTextToPageRanges("-1,1,2\u3001\u300156", 100)); }); TEST_F('PrintPreviewUtilsUnitTest', 'PageRangeTextToPageList', function() { diff --git a/chromium/chrome/browser/resources/print_preview/search/destination_list_item.js b/chromium/chrome/browser/resources/print_preview/search/destination_list_item.js index c6ef0c7b846..e31d26c99c1 100644 --- a/chromium/chrome/browser/resources/print_preview/search/destination_list_item.js +++ b/chromium/chrome/browser/resources/print_preview/search/destination_list_item.js @@ -72,6 +72,9 @@ cr.define('print_preview', function() { this.tracker.add( this.getChildElement('.register-promo-button'), 'click', this.onRegisterPromoClicked_.bind(this)); + this.tracker.add( + this.getChildElement('.learn-more-link'), 'click', + this.onGcpErrorLearnMoreClick_.bind(this)); }, /** @return {!print_preview.Destination} */ @@ -199,15 +202,10 @@ cr.define('print_preview', function() { // Initialize the element which renders the destination's connection // status. this.getElement().classList.toggle( - 'stale', - this.destination_.isOffline || - this.destination_.shouldShowInvalidCertificateError); + 'stale', this.destination_.isOfflineOrInvalid); const connectionStatusEl = this.getChildElement('.connection-status'); connectionStatusEl.textContent = this.destination_.connectionStatusText; - setIsVisible( - connectionStatusEl, - this.destination_.isOffline || - this.destination_.shouldShowInvalidCertificateError); + setIsVisible(connectionStatusEl, this.destination_.isOfflineOrInvalid); setIsVisible( this.getChildElement('.learn-more-link'), this.destination_.shouldShowInvalidCertificateError); @@ -324,6 +322,16 @@ cr.define('print_preview', function() { }, /** + * Called when the learn more link for an unsupported cloud destination is + * clicked. Opens the help page via native layer. + * @private + */ + onGcpErrorLearnMoreClick_: function() { + print_preview.NativeLayer.getInstance().forceOpenNewTab( + loadTimeData.getString('gcpCertificateErrorLearnMoreURL')); + }, + + /** * Handles click and 'Enter' key down events for the extension icon element. * It opens extensions page with the extension associated with the * destination highlighted. diff --git a/chromium/chrome/browser/resources/print_preview/search/destination_search.css b/chromium/chrome/browser/resources/print_preview/search/destination_search.css index fd7fa54103b..8eddc19d078 100644 --- a/chromium/chrome/browser/resources/print_preview/search/destination_search.css +++ b/chromium/chrome/browser/resources/print_preview/search/destination_search.css @@ -89,6 +89,10 @@ -webkit-margin-start: 10px; } +#destination-search .invitation-cloud-print-information { + padding-top: 12px; +} + #destination-search #invitation-process-throbber { display: block; } diff --git a/chromium/chrome/browser/resources/print_preview/search/destination_search.html b/chromium/chrome/browser/resources/print_preview/search/destination_search.html index 7d3c4c325e0..554855e835c 100644 --- a/chromium/chrome/browser/resources/print_preview/search/destination_search.html +++ b/chromium/chrome/browser/resources/print_preview/search/destination_search.html @@ -23,6 +23,9 @@ <button class="invitation-reject-button">$i18n{reject}</button> <div id="invitation-process-throbber" class="throbber" hidden></div> </div> + <div class="invitation-cloud-print-information"> + $i18nRaw{registerPrinterInformationMessage} + </div> </div> <div class="cloudprint-promo gray-bottom-bar" hidden> <img src="../images/cloud.png" class="icon" alt=""> diff --git a/chromium/chrome/browser/resources/print_preview/search/destination_search.js b/chromium/chrome/browser/resources/print_preview/search/destination_search.js index 2262082c5eb..6235d6f3c12 100644 --- a/chromium/chrome/browser/resources/print_preview/search/destination_search.js +++ b/chromium/chrome/browser/resources/print_preview/search/destination_search.js @@ -791,7 +791,7 @@ cr.define('print_preview', function() { */ onWindowResize_: function() { this.reflowLists_(); - } + }, }; // Export diff --git a/chromium/chrome/browser/resources/print_preview/settings/advanced_settings/advanced_settings_item.js b/chromium/chrome/browser/resources/print_preview/settings/advanced_settings/advanced_settings_item.js index b5ce64999e5..9c54b548ce9 100644 --- a/chromium/chrome/browser/resources/print_preview/settings/advanced_settings/advanced_settings_item.js +++ b/chromium/chrome/browser/resources/print_preview/settings/advanced_settings/advanced_settings_item.js @@ -1,24 +1,6 @@ // Copyright 2014 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. -/** - * Specifies a custom vendor capability. - * @typedef {{ - * id: (string), - * display_name: (string), - * localized_display_name: (string | undefined), - * type: (string), - * select_cap: ({ - * option: (Array<{ - * display_name: (string), - * type: (string | undefined), - * value: (number | string | boolean), - * is_default: (boolean | undefined) - * }>|undefined) - * }|undefined) - * }} - */ -print_preview.VendorCapability; cr.define('print_preview', function() { 'use strict'; diff --git a/chromium/chrome/browser/resources/print_preview/settings/destination_settings.js b/chromium/chrome/browser/resources/print_preview/settings/destination_settings.js index f8061808678..04b0362e2c4 100644 --- a/chromium/chrome/browser/resources/print_preview/settings/destination_settings.js +++ b/chromium/chrome/browser/resources/print_preview/settings/destination_settings.js @@ -139,15 +139,23 @@ cr.define('print_preview', function() { locationEl.textContent = hint; locationEl.title = hint; - const connectionStatusText = destination.connectionStatusText; + const showDestinationInvalid = + (destination.hasInvalidCertificate && + !loadTimeData.getBoolean('isEnterpriseManaged')); + let connectionStatusText = ''; + if (showDestinationInvalid) { + connectionStatusText = + loadTimeData.getString('noLongerSupportedFragment'); + } else { + connectionStatusText = destination.connectionStatusText; + } const connectionStatusEl = this.getChildElement('.destination-settings-connection-status'); connectionStatusEl.textContent = connectionStatusText; connectionStatusEl.title = connectionStatusText; - const hasConnectionError = destination.isOffline || - (destination.hasInvalidCertificate && - !loadTimeData.getBoolean('isEnterpriseManaged')); + const hasConnectionError = + destination.isOffline || showDestinationInvalid; destinationSettingsBoxEl.classList.toggle( print_preview.DestinationSettingsClasses_.STALE, hasConnectionError); diff --git a/chromium/chrome/browser/resources/safe_browsing/download_file_types.asciipb b/chromium/chrome/browser/resources/safe_browsing/download_file_types.asciipb index 04a91c17835..8634478bccc 100644 --- a/chromium/chrome/browser/resources/safe_browsing/download_file_types.asciipb +++ b/chromium/chrome/browser/resources/safe_browsing/download_file_types.asciipb @@ -8,7 +8,7 @@ ## ## Top level settings ## -version_id: 15 +version_id: 16 sampled_ping_probability: 0.01 max_archived_binaries_to_report: 10 default_file_type { @@ -797,7 +797,7 @@ file_types { file_types { # Opened by uTorrent and Transmission (can be a renamed .torrent) # Added crbug.com/767502 - extension: "btbtskin" + extension: "btskin" uma_value: 299 ping_setting: FULL_PING } diff --git a/chromium/chrome/browser/resources/settings/PRESUBMIT.py b/chromium/chrome/browser/resources/settings/PRESUBMIT.py new file mode 100644 index 00000000000..dcc145b7f91 --- /dev/null +++ b/chromium/chrome/browser/resources/settings/PRESUBMIT.py @@ -0,0 +1,24 @@ +# Copyright 2018 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. + + +def _CheckChangeOnUploadOrCommit(input_api, output_api): + import sys + old_sys_path, cwd = sys.path[:], input_api.PresubmitLocalPath() + src_root = input_api.os_path.join(cwd, '..', '..', '..', '..') + try: + sys.path += [input_api.os_path.join(src_root, 'tools', 'web_dev_style')] + import web_dev_style.presubmit_support + finally: + sys.path = old_sys_path + return web_dev_style.presubmit_support.DisallowIncludes(input_api, output_api, + '<include> does not work in settings; use HTML imports instead') + + +def CheckChangeOnUpload(input_api, output_api): + return _CheckChangeOnUploadOrCommit(input_api, output_api) + + +def CheckChangeOnCommit(input_api, output_api): + return _CheckChangeOnUploadOrCommit(input_api, output_api) diff --git a/chromium/chrome/browser/resources/settings/a11y_page/a11y_page.html b/chromium/chrome/browser/resources/settings/a11y_page/a11y_page.html index 8e9979679bd..3714da1774e 100644 --- a/chromium/chrome/browser/resources/settings/a11y_page/a11y_page.html +++ b/chromium/chrome/browser/resources/settings/a11y_page/a11y_page.html @@ -24,7 +24,7 @@ pref="{{prefs.settings.a11y.enable_menu}}"> </settings-toggle-button> <div id="subpage-trigger" class="settings-box two-line" - on-tap="onManageAccessibilityFeaturesTap_" actionable> + on-click="onManageAccessibilityFeaturesTap_" actionable> <div class="start"> $i18n{manageAccessibilityFeatures} <div class="secondary" id="themesSecondary"> diff --git a/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.html b/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.html index 08720eb78c2..59420dcd63f 100644 --- a/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.html +++ b/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.html @@ -16,11 +16,7 @@ -webkit-padding-start: var(--settings-box-row-padding); } - .list-item settings-dropdown-menu { - -webkit-margin-start: 16px; - } - - .sub-item > .start { + .sub-item { -webkit-margin-start: var(--settings-indent-width); } @@ -50,7 +46,7 @@ </settings-toggle-button> <iron-collapse opened="[[prefs.settings.accessibility.value]]"> <div class="settings-box" - on-tap="onChromeVoxSettingsTap_" actionable> + on-click="onChromeVoxSettingsTap_" actionable> <div class="start">$i18n{chromeVoxOptionsLabel}</div> <button class="icon-external" is="paper-icon-button-light" aria-label="$i18n{chromeVoxOptionsLabel}"></button> @@ -63,7 +59,7 @@ </settings-toggle-button> <iron-collapse opened="[[prefs.settings.a11y.select_to_speak.value]]"> <div class="settings-box" - on-tap="onSelectToSpeakSettingsTap_" actionable> + on-click="onSelectToSpeakSettingsTap_" actionable> <div class="start">$i18n{selectToSpeakOptionsLabel}</div> <button class="icon-external" is="paper-icon-button-light" aria-label="$i18n{selectToSpeakOptionsLabel}"></button> @@ -77,9 +73,33 @@ </settings-toggle-button> <settings-toggle-button class="continuation" pref="{{prefs.settings.a11y.screen_magnifier}}" - label="$i18n{screenMagnifierLabel}"> + label="$i18n{screenMagnifierLabel}" + disabled="[[prefs.ash.docked_magnifier.enabled.value]]"> </settings-toggle-button> - <div class="settings-box two-line" on-tap="onDisplayTap_" actionable> + <div class="settings-box continuation"> + <div class="start sub-item">$i18n{screenMagnifierZoomLabel}</div> + <settings-dropdown-menu label="$i18n{screenMagnifierZoomLabel}" + pref="{{prefs.settings.a11y.screen_magnifier_scale}}" + menu-options="[[screenMagnifierZoomOptions_]]" + disabled="[[!prefs.settings.a11y.screen_magnifier.value]]"> + </settings-dropdown-menu> + </div> + <template is="dom-if" if="[[dockedMagnifierFeatureEnabled_]]" restamp> + <settings-toggle-button class="continuation" + pref="{{prefs.ash.docked_magnifier.enabled}}" + label="$i18n{dockedMagnifierLabel}" + disabled="[[prefs.settings.a11y.screen_magnifier.value]]"> + </settings-toggle-button> + <div class="settings-box continuation"> + <div class="start sub-item">$i18n{dockedMagnifierZoomLabel}</div> + <settings-dropdown-menu label="$i18n{dockedMagnifierZoomLabel}" + pref="{{prefs.ash.docked_magnifier.scale}}" + menu-options="[[screenMagnifierZoomOptions_]]" + disabled="[[!prefs.ash.docked_magnifier.enabled.value]]"> + </settings-dropdown-menu> + </div> + </template> + <div class="settings-box two-line" on-click="onDisplayTap_" actionable> <div class="start"> $i18n{displaySettingsTitle} <div class="secondary">$i18n{displaySettingsDescription}</div> @@ -88,7 +108,7 @@ aria-label="$i18n{displaySettingsTitle}" aria-describedby="displaySettingsSecondary"></button> </div> - <div class="settings-box two-line" on-tap="onAppearanceTap_" actionable> + <div class="settings-box two-line" on-click="onAppearanceTap_" actionable> <div class="start"> $i18n{appearanceSettingsTitle} <div class="secondary" id="appearanceSettingsSecondary"> @@ -123,13 +143,13 @@ label="$i18n{switchAccessLabel}"> <button is="paper-icon-button-light" class="icon-settings" slot="more-actions" - on-tap="onSwitchAccessSettingsTap_" + on-click="onSwitchAccessSettingsTap_" hidden="[[!prefs.settings.a11y.switch_access.value]]" aria-label="$i18n{selectToSpeakOptionsLabel}"> </button> </settings-toggle-button> </template> - <div class="settings-box two-line" on-tap="onKeyboardTap_" actionable> + <div class="settings-box two-line" on-click="onKeyboardTap_" actionable> <div class="start"> $i18n{keyboardSettingsTitle} <div class="secondary" id="keyboardSettingsSecondary"> @@ -146,15 +166,13 @@ pref="{{prefs.settings.a11y.autoclick}}" label="$i18n{clickOnStopLabel}"> </settings-toggle-button> - <div class="settings-box block first"> - <div class="list-item sub-item"> - <div class="start">$i18n{delayBeforeClickLabel}</div> - <settings-dropdown-menu label="$i18n{delayBeforeClickLabel}" - pref="{{prefs.settings.a11y.autoclick_delay_ms}}" - menu-options="[[autoClickDelayOptions_]]" - disabled="[[!prefs.settings.a11y.autoclick.value]]"> - </settings-dropdown-menu> - </div> + <div class="settings-box continuation"> + <div class="start sub-item">$i18n{delayBeforeClickLabel}</div> + <settings-dropdown-menu label="$i18n{delayBeforeClickLabel}" + pref="{{prefs.settings.a11y.autoclick_delay_ms}}" + menu-options="[[autoClickDelayOptions_]]" + disabled="[[!prefs.settings.a11y.autoclick.value]]"> + </settings-dropdown-menu> </div> <settings-toggle-button class="continuation" pref="{{prefs.settings.touchpad.enable_tap_dragging}}" @@ -164,23 +182,21 @@ pref="{{prefs.settings.a11y.large_cursor_enabled}}" label="$i18n{largeMouseCursorLabel}"> </settings-toggle-button> - <div class="settings-box block continuation" + <div class="settings-box continuation" hidden$="[[!prefs.settings.a11y.large_cursor_enabled.value]]"> - <div class="list-item sub-item"> - <div class="start">$i18n{largeMouseCursorSizeLabel}</div> - <settings-slider - pref="{{prefs.settings.a11y.large_cursor_dip_size}}" - min="25" max="64" - label-min="$i18n{largeMouseCursorSizeDefaultLabel}" - label-max="$i18n{largeMouseCursorSizeLargeLabel}"> - </settings-slider> - </div> + <div class="start sub-item">$i18n{largeMouseCursorSizeLabel}</div> + <settings-slider + pref="{{prefs.settings.a11y.large_cursor_dip_size}}" + min="25" max="64" + label-min="$i18n{largeMouseCursorSizeDefaultLabel}" + label-max="$i18n{largeMouseCursorSizeLargeLabel}"> + </settings-slider> </div> <settings-toggle-button class="continuation" pref="{{prefs.settings.a11y.cursor_highlight}}" label="$i18n{cursorHighlightLabel}"> </settings-toggle-button> - <div class="settings-box two-line" on-tap="onMouseTap_" actionable> + <div class="settings-box two-line" on-click="onMouseTap_" actionable> <div class="start"> $i18n{mouseSettingsTitle} <div class="secondary" id="mouseSettingsSecondary"> diff --git a/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.js b/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.js index 79e39c1dd0e..090a8a7f74a 100644 --- a/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.js +++ b/chromium/chrome/browser/resources/settings/a11y_page/manage_a11y_page.js @@ -19,6 +19,28 @@ Polymer({ notify: true, }, + screenMagnifierZoomOptions_: { + readOnly: true, + type: Array, + value: function() { + // These values correspond to the i18n values in settings_strings.grdp. + // If these values get changed then those strings need to be changed as + // well. + return [ + {value: 2, name: loadTimeData.getString('screenMagnifierZoom2x')}, + {value: 4, name: loadTimeData.getString('screenMagnifierZoom4x')}, + {value: 6, name: loadTimeData.getString('screenMagnifierZoom6x')}, + {value: 8, name: loadTimeData.getString('screenMagnifierZoom8x')}, + {value: 10, name: loadTimeData.getString('screenMagnifierZoom10x')}, + {value: 12, name: loadTimeData.getString('screenMagnifierZoom12x')}, + {value: 14, name: loadTimeData.getString('screenMagnifierZoom14x')}, + {value: 16, name: loadTimeData.getString('screenMagnifierZoom16x')}, + {value: 18, name: loadTimeData.getString('screenMagnifierZoom18x')}, + {value: 20, name: loadTimeData.getString('screenMagnifierZoom20x')}, + ]; + }, + }, + autoClickDelayOptions_: { readOnly: true, type: Array, @@ -56,6 +78,17 @@ Polymer({ }, }, + /** + * Whether the docked magnifier flag is enabled. + * @private {boolean} + */ + dockedMagnifierFeatureEnabled_: { + type: Boolean, + value: function() { + return loadTimeData.getBoolean('dockedMagnifierFeatureEnabled'); + }, + }, + /** @private */ isGuest_: { type: Boolean, diff --git a/chromium/chrome/browser/resources/settings/about_page/about_page.html b/chromium/chrome/browser/resources/settings/about_page/about_page.html index 4f2f097bbe7..9053116b11d 100644 --- a/chromium/chrome/browser/resources/settings/about_page/about_page.html +++ b/chromium/chrome/browser/resources/settings/about_page/about_page.html @@ -78,7 +78,7 @@ <if expr="_google_chrome and is_macosx"> #promoteUpdater[disabled] { - @apply(--cr-secondary-text); + @apply --cr-secondary-text; } </if> </style> @@ -88,7 +88,7 @@ focus-config="[[focusConfig_]]"> <neon-animatable route-path="default"> <div class="settings-box two-line"> - <img id="product-logo" on-tap="onProductLogoTap_" + <img id="product-logo" on-click="onProductLogoTap_" srcset="chrome://theme/current-channel-logo@1x 1x, chrome://theme/current-channel-logo@2x 2x" alt="$i18n{aboutProductLogoAlt}"> @@ -153,18 +153,18 @@ <div class="separator" hidden="[[!showButtonContainer_]]"></div> <span id="buttonContainer" hidden="[[!showButtonContainer_]]"> <paper-button id="relaunch" class="secondary-button" - hidden="[[!showRelaunch_]]" on-tap="onRelaunchTap_"> + hidden="[[!showRelaunch_]]" on-click="onRelaunchTap_"> $i18n{aboutRelaunch} </paper-button> <if expr="chromeos"> <paper-button id="relaunchAndPowerwash" class="secondary-button" hidden="[[!showRelaunchAndPowerwash_]]" - on-tap="onRelaunchAndPowerwashTap_"> + on-click="onRelaunchAndPowerwashTap_"> $i18n{aboutRelaunchAndPowerwash} </paper-button> <paper-button id="checkForUpdates" class="secondary-button" hidden="[[!showCheckUpdates_]]" - on-tap="onCheckUpdatesTap_"> + on-click="onCheckUpdatesTap_"> $i18n{aboutCheckForUpdates} </paper-button> </if> @@ -173,13 +173,13 @@ <if expr="chromeos"> <div id="aboutTPMFirmwareUpdate" class="settings-box two-line" hidden$="[[!showTPMFirmwareUpdateLineItem_]]" - on-tap="onTPMFirmwareUpdateTap_" actionable> + on-click="onTPMFirmwareUpdateTap_" actionable> <div class="start"> <div>$i18n{aboutTPMFirmwareUpdateTitle}</div> <div class="secondary"> $i18n{aboutTPMFirmwareUpdateDescription} <a href="$i18n{aboutTPMFirmwareUpdateLearnMoreURL}" - target="_blank" on-tap="onLearnMoreTap_"> + target="_blank" on-click="onLearnMoreTap_"> $i18n{learnMore} </a> </div> @@ -194,12 +194,12 @@ <div id="promoteUpdater" class="settings-box" disabled$="[[promoteUpdaterStatus_.disabled]]" actionable$="[[promoteUpdaterStatus_.actionable]]" - on-tap="onPromoteUpdaterTap_"> + on-click="onPromoteUpdaterTap_"> <div class="start"> [[promoteUpdaterStatus_.text]] <a href="https://support.google.com/chrome/answer/95414" target="_blank" id="updaterLearnMore" - on-tap="onLearnMoreTap_"> + on-click="onLearnMoreTap_"> $i18n{learnMore} </a> </div> @@ -211,21 +211,22 @@ </div> </template> </if> - <div id="help" class="settings-box" on-tap="onHelpTap_" actionable> + <div id="help" class="settings-box" on-click="onHelpTap_" + actionable> <div class="start">$i18n{aboutGetHelpUsingChrome}</div> <button class="icon-external" is="paper-icon-button-light" aria-labelledby="help"></button> </div> <if expr="_google_chrome"> <div id="reportIssue" class="settings-box" actionable - on-tap="onReportIssueTap_"> + on-click="onReportIssueTap_"> <div class="start">$i18n{aboutReportAnIssue}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-labelledby="reportIssue"></button> </div> </if> <if expr="chromeos"> - <div class="settings-box" on-tap="onDetailedBuildInfoTap_" + <div class="settings-box" on-click="onDetailedBuildInfoTap_" actionable> <div class="start">$i18n{aboutDetailedBuildInfo}</div> <button id="detailed-build-info-trigger" class="subpage-arrow" diff --git a/chromium/chrome/browser/resources/settings/about_page/channel_switcher_dialog.html b/chromium/chrome/browser/resources/settings/about_page/channel_switcher_dialog.html index 9f58ef3661b..70cbc55efe9 100644 --- a/chromium/chrome/browser/resources/settings/about_page/channel_switcher_dialog.html +++ b/chromium/chrome/browser/resources/settings/about_page/channel_switcher_dialog.html @@ -53,15 +53,15 @@ </iron-selector> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_" + <paper-button class="cancel-button" on-click="onCancelTap_" id="cancel">$i18n{cancel}</paper-button> <paper-button id="changeChannel" class="action-button" - on-tap="onChangeChannelTap_" + on-click="onChangeChannelTap_" hidden="[[!shouldShowButtons_.changeChannel]]"> $i18n{aboutChangeChannel} </paper-button> <paper-button id="changeChannelAndPowerwash" class="action-button" - on-tap="onChangeChannelAndPowerwashTap_" + on-click="onChangeChannelAndPowerwashTap_" hidden="[[!shouldShowButtons_.changeChannelAndPowerwash]]"> $i18n{aboutChangeChannelAndPowerwash} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/about_page/detailed_build_info.html b/chromium/chrome/browser/resources/settings/about_page/detailed_build_info.html index a1ae3142cd9..08ab773cc7c 100644 --- a/chromium/chrome/browser/resources/settings/about_page/detailed_build_info.html +++ b/chromium/chrome/browser/resources/settings/about_page/detailed_build_info.html @@ -39,7 +39,7 @@ <div class="secondary">[[currentlyOnChannelText_]]</div> </div> <div class="separator"></div> - <paper-button on-tap="onChangeChannelTap_" + <paper-button on-click="onChangeChannelTap_" disabled="[[!canChangeChannel_]]"> $i18n{aboutChangeChannel} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/about_page/update_warning_dialog.html b/chromium/chrome/browser/resources/settings/about_page/update_warning_dialog.html index 3a336109f1f..e6dd4ab2540 100644 --- a/chromium/chrome/browser/resources/settings/about_page/update_warning_dialog.html +++ b/chromium/chrome/browser/resources/settings/about_page/update_warning_dialog.html @@ -16,9 +16,9 @@ </div> <div slot="button-container"> <paper-button id="cancel" class="cancel-button" - on-tap="onCancelTap_">$i18n{cancel}</paper-button> + on-click="onCancelTap_">$i18n{cancel}</paper-button> <paper-button id="continue" class="action-button" - on-tap="onContinueTap_"> + on-click="onContinueTap_"> $i18n{aboutUpdateWarningContinue} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_page.html b/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_page.html index a59514198c0..db780b0d718 100644 --- a/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_page.html +++ b/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_page.html @@ -23,7 +23,7 @@ <template is="dom-if" if="[[havePlayStoreApp]]" restamp> <div id="android-apps" class="settings-box two-line first" actionable$="[[androidAppsInfo.playStoreEnabled]]" - on-tap="onSubpageTap_"> + on-click="onSubpageTap_"> <div class="start"> $i18n{androidAppsPageLabel} <div class="secondary" id="secondaryText" @@ -43,7 +43,7 @@ <div class="separator"></div> <paper-button id="enable" class="secondary-button" disabled="[[isEnforced_(prefs.arc.enabled)]]" - on-tap="onEnableTap_" + on-click="onEnableTap_" aria-label="$i18n{androidAppsPageTitle}" aria-describedby="secondaryText"> $i18n{androidAppsEnable} diff --git a/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_subpage.html b/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_subpage.html index 58cc44902e0..771918fe76a 100644 --- a/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_subpage.html +++ b/chromium/chrome/browser/resources/settings/android_apps_page/android_apps_subpage.html @@ -21,7 +21,7 @@ </template> <template is="dom-if" if="[[allowRemove_(prefs.arc.enabled.*)]]"> - <div id="remove" class="settings-box" actionable on-tap="onRemoveTap_"> + <div id="remove" class="settings-box" actionable on-click="onRemoveTap_"> <div class="start">$i18n{androidAppsRemove}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{androidAppsRemove}"> @@ -37,11 +37,11 @@ <div slot="body" inner-h-t-m-l="[[dialogBody_]]"></div> <div slot="button-container"> <paper-button class="cancel-button" - on-tap="onConfirmDisableDialogCancel_"> + on-click="onConfirmDisableDialogCancel_"> $i18n{cancel} </paper-button> <paper-button class="action-button" - on-tap="onConfirmDisableDialogConfirm_"> + on-click="onConfirmDisableDialogConfirm_"> $i18n{androidAppsDisableDialogRemove} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/android_apps_page/android_settings_element.html b/chromium/chrome/browser/resources/settings/android_apps_page/android_settings_element.html index 17b6e861109..eb65859100f 100644 --- a/chromium/chrome/browser/resources/settings/android_apps_page/android_settings_element.html +++ b/chromium/chrome/browser/resources/settings/android_apps_page/android_settings_element.html @@ -12,7 +12,7 @@ <style include="settings-shared"></style> <div id="manageApps" class="settings-box first" on-keydown="onManageAndroidAppsKeydown_" - on-tap="onManageAndroidAppsTap_" actionable> + on-click="onManageAndroidAppsTap_" actionable> <div class="start"> <div>$i18n{androidAppsManageApps}</div> </div> diff --git a/chromium/chrome/browser/resources/settings/appearance_page/appearance_fonts_page.html b/chromium/chrome/browser/resources/settings/appearance_page/appearance_fonts_page.html index 3b014cc73a3..d82f8743fac 100644 --- a/chromium/chrome/browser/resources/settings/appearance_page/appearance_fonts_page.html +++ b/chromium/chrome/browser/resources/settings/appearance_page/appearance_fonts_page.html @@ -121,7 +121,7 @@ </div> <template is="dom-if" if="[[!isGuest_]]"> <div class="settings-box two-line" id="advancedButton" - on-tap="openAdvancedExtension_" actionable> + on-click="openAdvancedExtension_" actionable> <div class="start"> $i18n{advancedFontSettings} <div class="secondary" id="advancedButtonSublabel"> diff --git a/chromium/chrome/browser/resources/settings/appearance_page/appearance_page.html b/chromium/chrome/browser/resources/settings/appearance_page/appearance_page.html index 7ac5845998d..6667159ff4e 100644 --- a/chromium/chrome/browser/resources/settings/appearance_page/appearance_page.html +++ b/chromium/chrome/browser/resources/settings/appearance_page/appearance_page.html @@ -60,7 +60,7 @@ <button icon-class="icon-external" id="wallpaperButton" is="cr-link-row" hidden="[[!pageVisibility.setWallpaper]]" - on-tap="openWallpaperManager_" + on-click="openWallpaperManager_" label="$i18n{setWallpaper}" sub-label="$i18n{openWallpaperApp}" disabled="[[isWallpaperPolicyControlled_]]"> <template is="dom-if" if="[[isWallpaperPolicyControlled_]]"> @@ -76,11 +76,11 @@ <button class="first" icon-class="icon-external" is="cr-link-row" hidden="[[!pageVisibility.setTheme]]" label="$i18n{themes}" sub-label="[[themeSublabel_]]" - on-tap="openThemeUrl_"></button> + on-click="openThemeUrl_"></button> <if expr="not is_linux or chromeos"> <template is="dom-if" if="[[prefs.extensions.theme.id.value]]"> <div class="separator"></div> - <paper-button id="useDefault" on-tap="onUseDefaultTap_" + <paper-button id="useDefault" on-click="onUseDefaultTap_" class="secondary-button"> $i18n{resetToDefaultTheme} </paper-button> @@ -94,14 +94,14 @@ <div class="separator"></div> <template is="dom-if" if="[[showUseClassic_( prefs.extensions.theme.id.value, useSystemTheme_)]]" restamp> - <paper-button id="useDefault" on-tap="onUseDefaultTap_" + <paper-button id="useDefault" on-click="onUseDefaultTap_" class="secondary-button"> $i18n{useClassicTheme} </paper-button> </template> <template is="dom-if" if="[[showUseSystem_( prefs.extensions.theme.id.value, useSystemTheme_)]]" restamp> - <paper-button id="useSystem" on-tap="onUseSystemTap_" + <paper-button id="useSystem" on-click="onUseSystemTap_" class="secondary-button"> $i18n{useSystemTheme} </paper-button> @@ -168,7 +168,7 @@ </div> <button class="hr" is="cr-link-row" icon-class="subpage-arrow" id="customize-fonts-subpage-trigger" - label="$i18n{customizeFonts}" on-tap="onCustomizeFontsTap_"> + label="$i18n{customizeFonts}" on-click="onCustomizeFontsTap_"> </button> <div class="settings-box" hidden="[[!pageVisibility.pageZoom]]"> <div id="pageZoom" class="start">$i18n{pageZoom}</div> diff --git a/chromium/chrome/browser/resources/settings/basic_page/basic_page.html b/chromium/chrome/browser/resources/settings/basic_page/basic_page.html index 94029ec4769..80ab0b65c36 100644 --- a/chromium/chrome/browser/resources/settings/basic_page/basic_page.html +++ b/chromium/chrome/browser/resources/settings/basic_page/basic_page.html @@ -46,7 +46,7 @@ --paper-button: { text-transform: none; } - @apply(--settings-actionable); + @apply --settings-actionable; align-items: center; display: flex; margin-bottom: 3px; @@ -56,7 +56,7 @@ } #secondaryUserBanner { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; align-items: center; background-color: white; border-radius: 2px; diff --git a/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_device_list_item.html b/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_device_list_item.html index b7134f661f3..c3ddc427f33 100644 --- a/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_device_list_item.html +++ b/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_device_list_item.html @@ -33,14 +33,15 @@ <span hidden$="[[!device.connecting]]">$i18n{bluetoothConnecting}</span> <div hidden$="[[!device.paired]]"> <button is="paper-icon-button-light" class="icon-more-vert" - on-tap="onMenuButtonTap_" tabindex$="[[tabindex]]" + on-click="onMenuButtonTap_" tabindex$="[[tabindex]]" title="$i18n{moreActions}" on-keydown="ignoreEnterKey_"> </button> <dialog id="dotsMenu" is="cr-action-menu"> - <button class="dropdown-item" on-tap="onConnectActionTap_"> + <button slot="item" class="dropdown-item" + on-click="onConnectActionTap_"> [[getConnectActionText_(device.connected)]] </button> - <button class="dropdown-item" on-tap="onRemoveTap_"> + <button slot="item" class="dropdown-item" on-click="onRemoveTap_"> $i18n{bluetoothRemove} </button> </dialog> diff --git a/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_page.html b/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_page.html index fb27688ce72..1e386deda42 100644 --- a/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_page.html +++ b/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_page.html @@ -21,7 +21,7 @@ focus-config="[[focusConfig_]]"> <neon-animatable route-path="default"> <div id="bluetoothDevices" - class="settings-box two-line" actionable on-tap="onTap_"> + class="settings-box two-line" actionable on-click="onTap_"> <iron-icon icon="[[getIcon_(bluetoothToggleState_)]]"></iron-icon> <div class="middle"> $i18n{bluetoothPageTitle} @@ -36,7 +36,7 @@ </cr-policy-pref-indicator> <template is="dom-if" if="[[bluetoothToggleState_]]"> <button class="subpage-arrow" is="paper-icon-button-light" - on-tap="onSubpageArrowTap_" + on-click="onSubpageArrowTap_" aria-label="$i18n{bluetoothPageTitle}" aria-describedby="bluetoothSecondary"> </button> @@ -46,7 +46,7 @@ checked="{{bluetoothToggleState_}}" disabled$= "[[!isToggleEnabled_(adapterState_, stateChangeInProgress_)]]" - on-tap="stopTap_" + on-click="stopTap_" aria-label="$i18n{bluetoothToggleA11yLabel}"> </paper-toggle-button> </div> diff --git a/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_subpage.html b/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_subpage.html index 6ca9d1b16fa..018a505ac33 100644 --- a/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_subpage.html +++ b/chromium/chrome/browser/resources/settings/bluetooth_page/bluetooth_subpage.html @@ -15,7 +15,7 @@ <template> <style include="settings-shared iron-flex"> .container { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; display: flex; flex-direction: column; min-height: 10px; @@ -27,7 +27,7 @@ } paper-spinner-lite { - @apply(--cr-icon-height-width); + @apply --cr-icon-height-width; } #onOff { @@ -39,7 +39,7 @@ } </style> - <div class="settings-box first" actionable on-tap="onEnableTap_"> + <div class="settings-box first" actionable on-click="onEnableTap_"> <div id="onOff" class="start" on$="[[bluetoothToggleState]]"> [[getOnOffString_(bluetoothToggleState, '$i18nPolymer{deviceOn}', '$i18nPolymer{deviceOff}')]] @@ -48,7 +48,7 @@ checked="{{bluetoothToggleState}}" disabled$="[[!isToggleEnabled_(adapterState, stateChangeInProgress)]]" aria-label="$i18n{bluetoothToggleA11yLabel}" - on-tap="stopTap_"> + on-click="stopTap_"> </paper-toggle-button> </div> diff --git a/chromium/chrome/browser/resources/settings/change_password_page/change_password_page.html b/chromium/chrome/browser/resources/settings/change_password_page/change_password_page.html index deb22750820..ceaae386226 100644 --- a/chromium/chrome/browser/resources/settings/change_password_page/change_password_page.html +++ b/chromium/chrome/browser/resources/settings/change_password_page/change_password_page.html @@ -46,7 +46,7 @@ </div> <div class="separator"></div> <paper-button class="primary-button" id="changePassword" - on-tap="changePassword_"> + on-click="changePassword_"> $i18n{changePasswordPageButton} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.html b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.html index 245dbdd0765..d0cb57647aa 100644 --- a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.html +++ b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.html @@ -84,11 +84,11 @@ </iron-icon> </div> <div class="start"> - <div>[[title_]]</div> + <div role="status">[[title_]]</div> <div hidden="[[!showExplanation_]]"> <span class="secondary">[[explanation_]]</span> <a id="learn-more" href="$i18n{chromeCleanupLearnMoreUrl}" - on-tap="learnMore_" target="_blank" + on-click="learnMore_" target="_blank" hidden="[[!showLearnMore_]]"> $i18n{learnMore} </a> @@ -97,7 +97,7 @@ <template is="dom-if" if="[[showActionButton_]]"> <div class="separator"></div> <paper-button id="action-button" class="primary-button" - on-tap="proceed_"> + on-click="proceed_"> [[actionButtonLabel_]] </paper-button> </template> @@ -112,7 +112,7 @@ on-settings-boolean-control-change="changeLogsPermission_"> </settings-toggle-button> <div id="show-items-button" class="settings-box" actionable - on-tap="toggleExpandButton_" hidden="[[!showItemsToRemove_]]"> + on-click="toggleExpandButton_" hidden="[[!showItemsToRemove_]]"> <div class="start">[[showItemsLinkLabel_]]</div> <cr-expand-button expanded="{{itemsToRemoveSectionExpanded_}}" alt="[[showItemsLinkLabel_]]"> diff --git a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.js b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.js index ec8cc5aa7cc..7a31f0eef1d 100644 --- a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.js +++ b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/chrome_cleanup_page.js @@ -767,14 +767,14 @@ Polymer({ }, DISMISS_CLEANUP_SUCCESS: { - label: this.i18n('chromeCleanupDoneButtonLabel'), + label: this.i18n('done'), doAction: this.dismiss_.bind( this, settings.ChromeCleanupDismissSource.CLEANUP_SUCCESS_DONE_BUTTON), }, DISMISS_CLEANUP_FAILURE: { - label: this.i18n('chromeCleanupDoneButtonLabel'), + label: this.i18n('done'), doAction: this.dismiss_.bind( this, settings.ChromeCleanupDismissSource.CLEANUP_FAILURE_DONE_BUTTON), diff --git a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.html b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.html index 6f8b7eb1f77..21e3bb1cedc 100644 --- a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.html +++ b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.html @@ -20,18 +20,34 @@ color: var(--google-blue-500); cursor: pointer; } + + #remaining-list { + margin-top: -13px; + } </style> <div id="title" class="secondary" hidden="[[!titleVisible]]"> [[title]] </div> - <ul id="list" class="secondary"> - <template is="dom-repeat" items="[[visibleItems_]]"> + <ul class="secondary"> + <template is="dom-repeat" items="[[initialItems_]]"> <li class="visible-item">[[item]]</li> </template> - <li id="more-items-link" hidden="[[expanded_]]" on-tap="expandList_"> + <li id="more-items-link" hidden="[[expanded_]]" on-click="expandList_"> [[moreItemsLinkText_]] </li> </ul> + <!-- Remaining items are kept in a separate <ul> element so that screen + readers don't get confused when the list is expanded. If new items are + simply added to the first <ul> element, the first new item (which will + replace the "N more" link), will be skipped by the reader. As a + consequence, visual impaired users will only have a chance to inspect + that item if they move up on the list, which can't be considered an + expected action. --> + <ul id="remaining-list" hidden="[[!expanded_]]" class="secondary"> + <template is="dom-repeat" items="[[remainingItems_]]"> + <li class$="[[remainingItemsClass_(expanded_)]]">[[item]]</li> + </template> + </ul> </template> <script src="items_to_remove_list.js"></script> </dom-module> diff --git a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.js b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.js index e628437c50e..8b9c803fb3b 100644 --- a/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.js +++ b/chromium/chrome/browser/resources/settings/chrome_cleanup_page/items_to_remove_list.js @@ -71,11 +71,21 @@ Polymer({ }, /** - * The list of items to actually present on the card. If |expanded_|, then - * it's the same as |itemsToShow|. + * The items to be shown to the user the first time this component is + * rendered. If |initiallyExpanded| is true, then it includes all items + * from |itemsToShow|. Otherwise, it contains the first + * |CHROME_CLEANUP_DEFAULT_ITEMS_TO_SHOW| items. * @private {?Array<string>} */ - visibleItems_: Array, + initialItems_: Array, + + /** + * The remaining items to be presented that are not included in + * |initialItems_|. Items in this list are only shown to the user if + * |expanded_| is true. + * @private {?Array<string>} + */ + remainingItems_: Array, /** * The text for the "show more" link available if not all files are visible @@ -93,7 +103,6 @@ Polymer({ /** @private */ expandList_: function() { this.expanded_ = true; - this.visibleItems_ = this.itemsToShow; this.moreItemsLinkText_ = ''; }, @@ -118,25 +127,34 @@ Polymer({ updateVisibleState_: function(itemsToShow, initiallyExpanded) { // Start expanded if there are less than // |settings.CHROME_CLEANUP_DEFAULT_ITEMS_TO_SHOW| items to show. - this.expanded_ = this.initiallyExpanded || - this.itemsToShow.length <= - settings.CHROME_CLEANUP_DEFAULT_ITEMS_TO_SHOW; + this.expanded_ = initiallyExpanded || + itemsToShow.length <= settings.CHROME_CLEANUP_DEFAULT_ITEMS_TO_SHOW; if (this.expanded_) { - this.visibleItems_ = this.itemsToShow; + this.initialItems_ = itemsToShow; + this.remainingItems_ = []; this.moreItemsLinkText_ = ''; return; } - this.visibleItems_ = this.itemsToShow.slice( - 0, settings.CHROME_CLEANUP_DEFAULT_ITEMS_TO_SHOW - 1); + this.initialItems_ = + itemsToShow.slice(0, settings.CHROME_CLEANUP_DEFAULT_ITEMS_TO_SHOW - 1); + this.remainingItems_ = + itemsToShow.slice(settings.CHROME_CLEANUP_DEFAULT_ITEMS_TO_SHOW - 1); const browserProxy = settings.ChromeCleanupProxyImpl.getInstance(); - browserProxy - .getMoreItemsPluralString( - this.itemsToShow.length - this.visibleItems_.length) + browserProxy.getMoreItemsPluralString(this.remainingItems_.length) .then(linkText => { this.moreItemsLinkText_ = linkText; }); }, + + /** + * Returns the class for the <li> elements that correspond to the items hidden + * in the default view. + * @param {boolean} expanded + */ + remainingItemsClass_: function(expanded) { + return expanded ? 'visible-item' : 'hidden-item'; + }, }); diff --git a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.html b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.html index ec3fad23b77..dbbc94a3556 100644 --- a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.html +++ b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.html @@ -2,8 +2,10 @@ <link rel="import" href="chrome://resources/cr_elements/cr_dialog/cr_dialog.html"> <link rel="import" href="chrome://resources/html/web_ui_listener_behavior.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/iron-pages/iron-pages.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-spinner/paper-spinner-lite.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/paper-tabs/paper-tabs.html"> <link rel="import" href="../i18n_setup.html"> <link rel="import" href="clear_browsing_data_browser_proxy.html"> <link rel="import" href="history_deletion_dialog.html"> @@ -12,12 +14,29 @@ <link rel="import" href="../controls/settings_dropdown_menu.html"> <link rel="import" href="../icons.html"> <link rel="import" href="../settings_shared_css.html"> +<link rel="import" href="../settings_vars_css.html"> -<!-- This file is forked as clear_browsing_data_dialog_tabs.html until the new - CBD UI is launched. --> <dom-module id="settings-clear-browsing-data-dialog"> <template> <style include="settings-shared"> + :host { + /* Fixed height to allow multiple tabs with different height. + * The last entry in the advanced tab should show half an entry. + * crbug.com/652027 */ + --body-container-height: 322px; + } + + #clearBrowsingDataDialog { + --cr-dialog-top-container-min-height: 42px; + --cr-dialog-title: { + padding-bottom: 8px; + }; + --cr-dialog-body-container: { + border-top: 1px solid var(--paper-grey-300); + height: var(--body-container-height); + }; + } + #clearBrowsingDataDialog:not(.fully-rendered) { visibility: hidden; } @@ -26,6 +45,16 @@ color: var(--paper-grey-600); } + #clearBrowsingDataDialog [slot=body] { + padding-top: 8px; + } + + #importantSitesDialog { + --cr-dialog-body-container: { + height: var(--body-container-height); + }; + } + .row { align-items: center; display: flex; @@ -43,56 +72,36 @@ --settings-row-two-line-min-height: 48px; --settings-checkbox-label: { line-height: 1.25rem; - }; - } - - #generalFooter { - margin: 0; - min-height: 18px; - } - - #generalFooter iron-icon { - height: 18px; - padding: 1px; - width: 18px; - } - - #googleFooter { - margin: 0 0 0.8em 0; - min-height: 16px; - } - - #googleFooter iron-icon { - height: 16px; - padding: 2px; - width: 16px; - } - - [slot=footer] iron-icon { - margin: auto; + } } - .clear-browsing-data-footer { - -webkit-padding-start: 4px; - align-items: flex-start; - display: flex; - line-height: 1.538em; /* 20px/13px */ + #basic-tab settings-checkbox + settings-checkbox { + --settings-checkbox-margin-top: 12px; } - .clear-browsing-data-footer .footer-text { - -webkit-margin-start: 16px; + paper-tabs { + --paper-tabs-selection-bar-color: var(--google-blue-500); + --paper-tabs: { + font-size: 100%; + height: 40px; + } } - .clear-browsing-data-footer iron-icon { - flex-shrink: 0; + paper-tab { + --paper-tab-content: { + color: var(--google-blue-700); + }; + --paper-tab-content-unselected: { + opacity: 1; + color: var(--paper-grey-600); + }; } - .clear-browsing-data-footer a { - text-decoration: none; + .time-range-row { + margin-bottom: 12px; } - #clearFrom { - -webkit-margin-start: 0.5em; + .time-range-select { /* Adjust for md-select-underline and 1px additional bottom padding * to keep md-select's text (without the underline) aligned with * neighboring text that does not have an underline. */ @@ -103,115 +112,153 @@ font-size: calc(13 / 15 * 100%); padding-top: 8px; } - - /* Cap the height on smaller screens to avoid unfavorable clipping. - * Replace the bottom margin with padding to avoid the gap between - * the scrollbar and the bottom separator. */ - @media all and (max-height: 724px) { - #clearBrowsingDataDialog { - /* crbug.com/652027: Show four and a *half* items in the list. */ - --cr-dialog-body-container: { - max-height: 280px; - }; - } - } </style> <dialog is="cr-dialog" id="clearBrowsingDataDialog" on-close="onClearBrowsingDataDialogClose_" - close-text="$i18n{close}" ignore-popstate> - <div slot="title">$i18n{clearBrowsingData}</div> + close-text="$i18n{close}" ignore-popstate has-tabs> + <div slot="title"> + <div>$i18n{clearBrowsingData}</div> + </div> + <div slot="header"> + <paper-tabs noink on-selected-changed="recordTabChange_" + selected="{{prefs.browser.last_clear_browsing_data_tab.value}}"> + <paper-tab>$i18n{basicPageTitle}</paper-tab> + <paper-tab>$i18n{advancedPageTitle}</paper-tab> + </paper-tabs> + </div> <div slot="body"> - <div class="row"> - $i18n{clearFollowingItemsFrom} - <settings-dropdown-menu id="clearFrom" - label="$i18n{clearFollowingItemsFrom}" - pref="{{prefs.browser.clear_data.time_period}}" - menu-options="[[clearFromOptions_]]"> - </settings-dropdown-menu> - </div> - <!-- Note: whether these checkboxes are checked are ignored if deleting - history is disabled (i.e. supervised users, policy), so it's OK to - have a hidden checkbox that's also checked (as the C++ accounts for - whether a user is allowed to delete history independently). --> - <settings-checkbox id="browsingCheckbox" class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.browsing_history}}" - label="$i18n{clearBrowsingHistory}" - sub-label="[[counters_.browsing_history]]" - disabled="[[clearingInProgress_]]" - hidden="[[isSupervised_]]"> - </settings-checkbox> - <settings-checkbox id="downloadCheckbox" class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.download_history}}" - label="$i18n{clearDownloadHistory}" - sub-label="[[counters_.download_history]]" - disabled="[[clearingInProgress_]]" - hidden="[[isSupervised_]]"> - </settings-checkbox> - <settings-checkbox id="cacheCheckbox" class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.cache}}" - label="$i18n{clearCache}" - sub-label="[[counters_.cache]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox id="cookiesCheckbox" class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.cookies}}" - label="$i18n{clearCookies}" - sub-label="$i18n{clearCookiesCounter}" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.passwords}}" - label="$i18n{clearPasswords}" - sub-label="[[counters_.passwords]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.form_data}}" - label="$i18n{clearFormData}" - sub-label="[[counters_.form_data]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.hosted_apps_data}}" - label="$i18n{clearHostedAppData}" - sub-label="[[counters_.hosted_apps_data]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox class="browsing-data-checkbox" - pref="{{prefs.browser.clear_data.media_licenses}}" - label="$i18n{clearMediaLicenses}" - sub-label="[[counters_.media_licenses]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> + <iron-pages id="tabs" + selected="[[prefs.browser.last_clear_browsing_data_tab.value]]"> + <div id="basic-tab"> + <div class="row time-range-row"> + <span class="time-range-label"> + $i18n{clearTimeRange} + </span> + <settings-dropdown-menu id="clearFromBasic" + class="time-range-select" + label="$i18n{clearTimeRange}" + pref="{{prefs.browser.clear_data.time_period_basic}}" + menu-options="[[clearFromOptions_]]"> + </settings-dropdown-menu> + </div> + <!-- Note: whether these checkboxes are checked are ignored if + deleting history is disabled (i.e. supervised users, policy), + so it's OK to have a hidden checkbox that's also checked (as + the C++ accounts for whether a user is allowed to delete + history independently). --> + <settings-checkbox id="browsingCheckboxBasic" + pref="{{prefs.browser.clear_data.browsing_history_basic}}" + label="$i18n{clearBrowsingHistory}" + sub-label-html="[[browsingCheckboxLabel_( + isSignedIn_, isSyncingHistory_, + '$i18nPolymer{clearBrowsingHistorySummary}', + '$i18nPolymer{clearBrowsingHistorySummarySignedIn}', + '$i18nPolymer{clearBrowsingHistorySummarySynced}')]]" + disabled="[[clearingInProgress_]]" + hidden="[[isSupervised_]]"> + </settings-checkbox> + <settings-checkbox id="cookiesCheckboxBasic" + class="cookies-checkbox" + pref="{{prefs.browser.clear_data.cookies_basic}}" + label="$i18n{clearCookies}" + sub-label="$i18n{clearCookiesSummary}" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + <settings-checkbox id="cacheCheckboxBasic" + class="cache-checkbox" + pref="{{prefs.browser.clear_data.cache_basic}}" + label="$i18n{clearCache}" + sub-label="[[counters_.cache_basic]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + </div> + <div id="advanced-tab"> + <div class="row time-range-row"> + <span class="time-range-label"> + $i18n{clearTimeRange} + </span> + <settings-dropdown-menu id="clearFrom" + class="time-range-select" + label="$i18n{clearTimeRange}" + pref="{{prefs.browser.clear_data.time_period}}" + menu-options="[[clearFromOptions_]]"> + </settings-dropdown-menu> + </div> + <settings-checkbox id="browsingCheckbox" + pref="{{prefs.browser.clear_data.browsing_history}}" + label="$i18n{clearBrowsingHistory}" + sub-label="[[counters_.browsing_history]]" + disabled="[[clearingInProgress_]]" + hidden="[[isSupervised_]]"> + </settings-checkbox> + <settings-checkbox id="downloadCheckbox" + pref="{{prefs.browser.clear_data.download_history}}" + label="$i18n{clearDownloadHistory}" + sub-label="[[counters_.download_history]]" + disabled="[[clearingInProgress_]]" + hidden="[[isSupervised_]]"> + </settings-checkbox> + <settings-checkbox id="cookiesCheckbox" + class="cookies-checkbox" + pref="{{prefs.browser.clear_data.cookies}}" + label="$i18n{clearCookies}" + sub-label="[[counters_.cookies]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + <settings-checkbox id="cacheCheckbox" + class="cache-checkbox" + pref="{{prefs.browser.clear_data.cache}}" + label="$i18n{clearCache}" + sub-label="[[counters_.cache]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + <settings-checkbox + pref="{{prefs.browser.clear_data.passwords}}" + label="$i18n{clearPasswords}" + sub-label="[[counters_.passwords]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + <settings-checkbox + pref="{{prefs.browser.clear_data.form_data}}" + label="$i18n{clearFormData}" + sub-label="[[counters_.form_data]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + <settings-checkbox + pref="{{prefs.browser.clear_data.site_settings}}" + label="[[siteSettingsLabel_( + '$i18nPolymer{siteSettings}', + '$i18nPolymer{contentSettings}')]]" + sub-label="[[counters_.site_settings]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + <settings-checkbox + pref="{{prefs.browser.clear_data.hosted_apps_data}}" + label="$i18n{clearHostedAppData}" + sub-label="[[counters_.hosted_apps_data]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + <settings-checkbox + pref="{{prefs.browser.clear_data.media_licenses}}" + label="$i18n{clearMediaLicenses}" + sub-label="[[counters_.media_licenses]]" + disabled="[[clearingInProgress_]]"> + </settings-checkbox> + </div> + </iron-pages> </div> <div slot="button-container"> <paper-spinner-lite active="[[clearingInProgress_]]"> </paper-spinner-lite> <paper-button class="cancel-button" disabled="[[clearingInProgress_]]" - on-tap="onCancelTap_">$i18n{cancel}</paper-button> + on-click="onCancelTap_">$i18n{cancel}</paper-button> <paper-button id="clearBrowsingDataConfirm" class="action-button" disabled="[[clearingInProgress_]]" - on-tap="onClearBrowsingDataTap_"> - $i18n{clearBrowsingData} + on-click="onClearBrowsingDataTap_"> + $i18n{clearData} </paper-button> </div> - <div slot="footer"> - <div id="googleFooter" class="clear-browsing-data-footer"> - <iron-icon icon="settings:googleg"></iron-icon> - <div class="footer-text">$i18nRaw{otherFormsOfBrowsingHistory}</div> - </div> - <div id="generalFooter" class="clear-browsing-data-footer"> - <iron-icon icon="settings:info"></iron-icon> - <div class="footer-text"> - <span id="syncedDataSentence">$i18n{clearsSyncedData}</span> - <span>$i18n{warnAboutNonClearedData}</span> - <a id="clear-browser-data-old-learn-more-link" - href="$i18n{clearBrowsingDataLearnMoreUrl}" - target="_blank">$i18n{learnMore}</a> - </div> - </div> - </div> </dialog> <template is="dom-if" if="[[showImportantSitesDialog_]]"> @@ -220,13 +267,12 @@ <div slot="title"> $i18n{clearBrowsingData} <div class="secondary"> - <template is="dom-if" - if="[[!prefs.browser.clear_data.cache.value]]"> + <span hidden$="[[showImportantSitesCacheSubtitle_]]"> $i18n{importantSitesSubtitleCookies} - </template> - <template is="dom-if" if="[[prefs.browser.clear_data.cache.value]]"> + </span> + <span hidden$="[[!showImportantSitesCacheSubtitle_]]"> $i18n{importantSitesSubtitleCookiesAndCache} - </template> + </span> </div> </div> <div slot="body"> @@ -243,10 +289,10 @@ <paper-spinner-lite active="[[clearingInProgress_]]"> </paper-spinner-lite> <paper-button class="cancel-button" disabled="[[clearingInProgress_]]" - on-tap="onImportantSitesCancelTap_">$i18n{cancel}</paper-button> + on-click="onImportantSitesCancelTap_">$i18n{cancel}</paper-button> <paper-button id="importantSitesConfirm" class="action-button" disabled="[[clearingInProgress_]]" - on-tap="onImportantSitesConfirmTap_"> + on-click="onImportantSitesConfirmTap_"> $i18n{importantSitesConfirm} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.js b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.js index 60ec1ccbf33..97beb3c7906 100644 --- a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.js +++ b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog.js @@ -3,16 +3,13 @@ // found in the LICENSE file. /** - * @fileoverview 'settings-clear-browsing-data-dialog' allows the user to delete - * browsing data that has been cached by Chromium. - * - * This file is forked as clear_browsing_data_dialog_tabs.js until the new - * CBD UI is launched. + * @fileoverview 'settings-clear-browsing-data-dialog' allows the user to + * delete browsing data that has been cached by Chromium. */ Polymer({ is: 'settings-clear-browsing-data-dialog', - behaviors: [WebUIListenerBehavior], + behaviors: [WebUIListenerBehavior, settings.RouteObserverBehavior], properties: { /** @@ -45,11 +42,11 @@ Polymer({ readOnly: true, type: Array, value: [ - {value: 0, name: loadTimeData.getString('clearDataHour')}, - {value: 1, name: loadTimeData.getString('clearDataDay')}, - {value: 2, name: loadTimeData.getString('clearDataWeek')}, - {value: 3, name: loadTimeData.getString('clearData4Weeks')}, - {value: 4, name: loadTimeData.getString('clearDataEverything')}, + {value: 0, name: loadTimeData.getString('clearPeriodHour')}, + {value: 1, name: loadTimeData.getString('clearPeriod24Hours')}, + {value: 2, name: loadTimeData.getString('clearPeriod7Days')}, + {value: 3, name: loadTimeData.getString('clearPeriod4Weeks')}, + {value: 4, name: loadTimeData.getString('clearPeriodEverything')}, ], }, @@ -73,6 +70,18 @@ Polymer({ value: false, }, + /** @private */ + isSignedIn_: { + type: Boolean, + value: false, + }, + + /** @private */ + isSyncingHistory_: { + type: Boolean, + value: false, + }, + /** @private {!Array<ImportantSite>} */ importantSites_: { type: Array, @@ -90,7 +99,25 @@ Polymer({ }, /** @private */ - showImportantSitesDialog_: {type: Boolean, value: false}, + showImportantSitesDialog_: { + type: Boolean, + value: false, + }, + + /** @private */ + showImportantSitesCacheSubtitle_: { + type: Boolean, + value: false, + }, + + /** + * Time in ms, when the dialog was opened. + * @private + */ + dialogOpenedTime_: { + type: Number, + value: 0, + } }, /** @private {settings.ClearBrowsingDataBrowserProxy} */ @@ -98,7 +125,6 @@ Polymer({ /** @override */ ready: function() { - this.$.clearFrom.menuOptions = this.clearFromOptions_; this.addWebUIListener( 'update-sync-state', this.updateSyncState_.bind(this)); this.addWebUIListener( @@ -109,6 +135,7 @@ Polymer({ attached: function() { this.browserProxy_ = settings.ClearBrowsingDataBrowserProxyImpl.getInstance(); + this.dialogOpenedTime_ = Date.now(); this.browserProxy_.initialize().then(() => { this.$.clearBrowsingDataDialog.showModal(); }); @@ -121,21 +148,67 @@ Polymer({ }, /** - * Updates the footer to show only those sentences that are relevant to this - * user. + * Record visits to the CBD dialog. + * + * settings.RouteObserverBehavior + * @param {!settings.Route} currentRoute + * @protected + */ + currentRouteChanged: function(currentRoute) { + if (currentRoute == settings.routes.CLEAR_BROWSER_DATA) { + chrome.metricsPrivate.recordUserAction('ClearBrowsingData_DialogCreated'); + this.dialogOpenedTime_ = Date.now(); + } + }, + + /** + * Updates the history description to show the relevant information + * depending on sync and signin state. + * * @param {boolean} signedIn Whether the user is signed in. - * @param {boolean} syncing Whether the user is syncing data. - * @param {boolean} otherFormsOfBrowsingHistory Whether the user has other - * forms of browsing history in their account. + * @param {boolean} syncing Whether the user is syncing history. * @private */ - updateSyncState_: function(signedIn, syncing, otherFormsOfBrowsingHistory) { - this.$.googleFooter.hidden = !otherFormsOfBrowsingHistory; - this.$.syncedDataSentence.hidden = !syncing; + updateSyncState_: function(signedIn, syncing) { + this.isSignedIn_ = signedIn; + this.isSyncingHistory_ = syncing; this.$.clearBrowsingDataDialog.classList.add('fully-rendered'); }, /** + * Choose a summary checkbox label. + * @param {boolean} isSignedIn + * @param {boolean} isSyncingHistory + * @param {string} historySummary + * @param {string} historySummarySigned + * @param {string} historySummarySynced + * @return {string} + * @private + */ + browsingCheckboxLabel_: function( + isSignedIn, isSyncingHistory, historySummary, historySummarySigned, + historySummarySynced) { + if (isSyncingHistory) { + return historySummarySynced; + } else if (isSignedIn) { + return historySummarySigned; + } + return historySummary; + }, + + /** + * Choose a content/site settings label. + * @param {string} siteSettings + * @param {string} contentSettings + * @return {string} + * @private + */ + siteSettingsLabel_: function(siteSettings, contentSettings) { + return loadTimeData.getBoolean('enableSiteSettings') ? siteSettings : + contentSettings; + }, + + /** * Updates the text of a browsing data counter corresponding to the given * preference. * @param {string} prefName Browsing data type deletion preference. @@ -156,8 +229,10 @@ Polymer({ shouldShowImportantSites_: function() { if (!this.importantSitesFlagEnabled_) return false; - if (!this.$.cookiesCheckbox.checked) + const tab = this.$.tabs.selectedItem; + if (!tab.querySelector('.cookies-checkbox').checked) { return false; + } const haveImportantSites = this.importantSites_.length > 0; chrome.send( @@ -172,12 +247,13 @@ Polymer({ */ onClearBrowsingDataTap_: function() { if (this.shouldShowImportantSites_()) { + const tab = this.$.tabs.selectedItem; this.showImportantSitesDialog_ = true; + this.showImportantSitesCacheSubtitle_ = + tab.querySelector('.cache-checkbox').checked; this.$.clearBrowsingDataDialog.close(); // Show important sites dialog after dom-if is applied. - this.async(function() { - this.$$('#importantSitesDialog').showModal(); - }); + this.async(() => this.$$('#importantSitesDialog').showModal()); } else { this.clearBrowsingData_(); } @@ -200,21 +276,31 @@ Polymer({ */ clearBrowsingData_: function() { this.clearingInProgress_ = true; + const tab = this.$.tabs.selectedItem; - const checkboxes = this.root.querySelectorAll('.browsing-data-checkbox'); + const checkboxes = tab.querySelectorAll('settings-checkbox'); const dataTypes = []; checkboxes.forEach((checkbox) => { if (checkbox.checked) dataTypes.push(checkbox.pref.key); }); - const timePeriod = this.$.clearFrom.pref.value; + const timePeriod = tab.querySelector('.time-range-select').pref.value; + + if (tab.id == 'basic-tab') { + chrome.metricsPrivate.recordUserAction('ClearBrowsingData_BasicTab'); + } else { + chrome.metricsPrivate.recordUserAction('ClearBrowsingData_AdvancedTab'); + } this.browserProxy_ .clearBrowsingData(dataTypes, timePeriod, this.importantSites_) .then(shouldShowNotice => { this.clearingInProgress_ = false; this.showHistoryDeletionDialog_ = shouldShowNotice; + chrome.metricsPrivate.recordMediumTime( + 'History.ClearBrowsingData.TimeSpentInDialog', + Date.now() - this.dialogOpenedTime_); if (!shouldShowNotice) this.closeDialogs_(); }); @@ -257,4 +343,20 @@ Polymer({ this.showHistoryDeletionDialog_ = false; this.closeDialogs_(); }, + + /** + * Records an action when the user changes between the basic and advanced tab. + * @param {!Event} event + * @private + */ + recordTabChange_: function(event) { + if (event.detail.value == 0) { + chrome.metricsPrivate.recordUserAction( + 'ClearBrowsingData_SwitchTo_BasicTab'); + } else { + chrome.metricsPrivate.recordUserAction( + 'ClearBrowsingData_SwitchTo_AdvancedTab'); + } + }, + }); diff --git a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.html b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.html deleted file mode 100644 index b9118df038f..00000000000 --- a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.html +++ /dev/null @@ -1,311 +0,0 @@ -<link rel="import" href="chrome://resources/html/polymer.html"> - -<link rel="import" href="chrome://resources/cr_elements/cr_dialog/cr_dialog.html"> -<link rel="import" href="chrome://resources/html/web_ui_listener_behavior.html"> -<link rel="import" href="chrome://resources/polymer/v1_0/iron-pages/iron-pages.html"> -<link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> -<link rel="import" href="chrome://resources/polymer/v1_0/paper-spinner/paper-spinner-lite.html"> -<link rel="import" href="chrome://resources/polymer/v1_0/paper-tabs/paper-tabs.html"> -<link rel="import" href="../i18n_setup.html"> -<link rel="import" href="clear_browsing_data_browser_proxy.html"> -<link rel="import" href="history_deletion_dialog.html"> -<link rel="import" href="../controls/important_site_checkbox.html"> -<link rel="import" href="../controls/settings_checkbox.html"> -<link rel="import" href="../controls/settings_dropdown_menu.html"> -<link rel="import" href="../icons.html"> -<link rel="import" href="../settings_shared_css.html"> -<link rel="import" href="../settings_vars_css.html"> - -<!-- This file is a fork of clear_browsing_data_dialog.html until the new CBD - UI is launched. --> -<dom-module id="settings-clear-browsing-data-dialog-tabs"> - <template> - <style include="settings-shared"> - :host { - /* Fixed height to allow multiple tabs with different height. - * The last entry in the advanced tab should show half an entry. - * crbug.com/652027 */ - --body-container-height: 322px; - } - - #clearBrowsingDataDialog { - --cr-dialog-top-container-min-height: 42px; - --cr-dialog-title: { - padding-bottom: 8px; - }; - --cr-dialog-body-container: { - border-top: 1px solid var(--paper-grey-300); - height: var(--body-container-height); - }; - } - - #clearBrowsingDataDialog:not(.fully-rendered) { - visibility: hidden; - } - - #clearBrowsingDataDialog [slot=footer] { - color: var(--paper-grey-600); - } - - #clearBrowsingDataDialog [slot=body] { - padding-top: 8px; - } - - #importantSitesDialog { - --cr-dialog-body-container: { - height: var(--body-container-height); - }; - } - - .row { - align-items: center; - display: flex; - min-height: 40px; - } - - paper-spinner-lite { - -webkit-margin-end: 16px; - margin-bottom: auto; - margin-top: auto; - } - - settings-checkbox, - important-site-checkbox { - --settings-row-two-line-min-height: 48px; - --settings-checkbox-label: { - line-height: 1.25rem; - } - } - - #basic-tab settings-checkbox + settings-checkbox { - --settings-checkbox-margin-top: 12px; - } - - paper-tabs { - --paper-tabs-selection-bar-color: var(--google-blue-500); - --paper-tabs: { - font-size: 100%; - height: 40px; - } - } - - paper-tab { - --paper-tab-content: { - color: var(--google-blue-700); - }; - --paper-tab-content-unselected: { - opacity: 1; - color: var(--paper-grey-600); - }; - } - - .time-range-row { - margin-bottom: 12px; - } - - .time-range-select { - /* Adjust for md-select-underline and 1px additional bottom padding - * to keep md-select's text (without the underline) aligned with - * neighboring text that does not have an underline. */ - margin-top: 3px; - } - - [slot=title] .secondary { - font-size: calc(13 / 15 * 100%); - padding-top: 8px; - } - </style> - - <dialog is="cr-dialog" id="clearBrowsingDataDialog" - on-close="onClearBrowsingDataDialogClose_" - close-text="$i18n{close}" ignore-popstate has-tabs> - <div slot="title"> - <div>$i18n{clearBrowsingData}</div> - </div> - <div slot="header"> - <paper-tabs noink on-selected-changed="recordTabChange_" - selected="{{prefs.browser.last_clear_browsing_data_tab.value}}"> - <paper-tab>$i18n{basicPageTitle}</paper-tab> - <paper-tab>$i18n{advancedPageTitle}</paper-tab> - </paper-tabs> - </div> - <div slot="body"> - <iron-pages id="tabs" - selected="[[prefs.browser.last_clear_browsing_data_tab.value]]"> - <div id="basic-tab"> - <div class="row time-range-row"> - <span class="time-range-label"> - $i18n{clearTimeRange} - </span> - <settings-dropdown-menu id="clearFromBasic" - class="time-range-select" - label="$i18n{clearTimeRange}" - pref="{{prefs.browser.clear_data.time_period_basic}}" - menu-options="[[clearFromOptions_]]"> - </settings-dropdown-menu> - </div> - <!-- Note: whether these checkboxes are checked are ignored if - deleting history is disabled (i.e. supervised users, policy), - so it's OK to have a hidden checkbox that's also checked (as - the C++ accounts for whether a user is allowed to delete - history independently). --> - <settings-checkbox id="browsingCheckboxBasic" - pref="{{prefs.browser.clear_data.browsing_history_basic}}" - label="$i18n{clearBrowsingHistory}" - sub-label-html="[[browsingCheckboxLabel_( - isSignedIn_, isSyncingHistory_, - '$i18nPolymer{clearBrowsingHistorySummary}', - '$i18nPolymer{clearBrowsingHistorySummarySignedIn}', - '$i18nPolymer{clearBrowsingHistorySummarySynced}')]]" - disabled="[[clearingInProgress_]]" - hidden="[[isSupervised_]]"> - </settings-checkbox> - <settings-checkbox id="cookiesCheckboxBasic" - class="cookies-checkbox" - pref="{{prefs.browser.clear_data.cookies_basic}}" - label="$i18n{clearCookies}" - sub-label="$i18n{clearCookiesSummary}" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox id="cacheCheckboxBasic" - class="cache-checkbox" - pref="{{prefs.browser.clear_data.cache_basic}}" - label="$i18n{clearCache}" - sub-label="[[counters_.cache_basic]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - </div> - <div id="advanced-tab"> - <div class="row time-range-row"> - <span class="time-range-label"> - $i18n{clearTimeRange} - </span> - <settings-dropdown-menu id="clearFrom" - class="time-range-select" - label="$i18n{clearTimeRange}" - pref="{{prefs.browser.clear_data.time_period}}" - menu-options="[[clearFromOptions_]]"> - </settings-dropdown-menu> - </div> - <settings-checkbox id="browsingCheckbox" - pref="{{prefs.browser.clear_data.browsing_history}}" - label="$i18n{clearBrowsingHistory}" - sub-label="[[counters_.browsing_history]]" - disabled="[[clearingInProgress_]]" - hidden="[[isSupervised_]]"> - </settings-checkbox> - <settings-checkbox id="downloadCheckbox" - pref="{{prefs.browser.clear_data.download_history}}" - label="$i18n{clearDownloadHistory}" - sub-label="[[counters_.download_history]]" - disabled="[[clearingInProgress_]]" - hidden="[[isSupervised_]]"> - </settings-checkbox> - <settings-checkbox id="cookiesCheckbox" - class="cookies-checkbox" - pref="{{prefs.browser.clear_data.cookies}}" - label="$i18n{clearCookies}" - sub-label="[[counters_.cookies]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox id="cacheCheckbox" - class="cache-checkbox" - pref="{{prefs.browser.clear_data.cache}}" - label="$i18n{clearCache}" - sub-label="[[counters_.cache]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox - pref="{{prefs.browser.clear_data.passwords}}" - label="$i18n{clearPasswords}" - sub-label="[[counters_.passwords]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox - pref="{{prefs.browser.clear_data.form_data}}" - label="$i18n{clearFormData}" - sub-label="[[counters_.form_data]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox - pref="{{prefs.browser.clear_data.site_settings}}" - label="[[siteSettingsLabel_( - '$i18nPolymer{siteSettings}', - '$i18nPolymer{contentSettings}')]]" - sub-label="[[counters_.site_settings]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox - pref="{{prefs.browser.clear_data.hosted_apps_data}}" - label="$i18n{clearHostedAppData}" - sub-label="[[counters_.hosted_apps_data]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - <settings-checkbox - pref="{{prefs.browser.clear_data.media_licenses}}" - label="$i18n{clearMediaLicenses}" - sub-label="[[counters_.media_licenses]]" - disabled="[[clearingInProgress_]]"> - </settings-checkbox> - </div> - </iron-pages> - </div> - <div slot="button-container"> - <paper-spinner-lite active="[[clearingInProgress_]]"> - </paper-spinner-lite> - <paper-button class="cancel-button" disabled="[[clearingInProgress_]]" - on-tap="onCancelTap_">$i18n{cancel}</paper-button> - <paper-button id="clearBrowsingDataConfirm" - class="action-button" disabled="[[clearingInProgress_]]" - on-tap="onClearBrowsingDataTap_"> - $i18n{clearData} - </paper-button> - </div> - </dialog> - - <template is="dom-if" if="[[showImportantSitesDialog_]]"> - <dialog is="cr-dialog" id="importantSitesDialog" close-text="$i18n{close}" - show-scroll-borders ignore-popstate> - <div slot="title"> - $i18n{clearBrowsingData} - <div class="secondary"> - <span hidden$="[[showImportantSitesCacheSubtitle_]]"> - $i18n{importantSitesSubtitleCookies} - </span> - <span hidden$="[[!showImportantSitesCacheSubtitle_]]"> - $i18n{importantSitesSubtitleCookiesAndCache} - </span> - </div> - </div> - <div slot="body"> - <template is="dom-repeat" items="[[importantSites_]]"> - <div class="row"> - <important-site-checkbox - site="[[item]]" - disabled="[[clearingInProgress_]]"> - </important-site-checkbox> - </div> - </template> - </div> - <div slot="button-container"> - <paper-spinner-lite active="[[clearingInProgress_]]"> - </paper-spinner-lite> - <paper-button class="cancel-button" disabled="[[clearingInProgress_]]" - on-tap="onImportantSitesCancelTap_">$i18n{cancel}</paper-button> - <paper-button id="importantSitesConfirm" - class="action-button" disabled="[[clearingInProgress_]]" - on-tap="onImportantSitesConfirmTap_"> - $i18n{importantSitesConfirm} - </paper-button> - </div> - </dialog> - </template> - - <template is="dom-if" if="[[showHistoryDeletionDialog_]]" restamp> - <settings-history-deletion-dialog id="notice" - on-close="onHistoryDeletionDialogClose_"> - </settings-history-deletion-dialog> - </template> - </template> - <script src="clear_browsing_data_dialog_tabs.js"></script> -</dom-module> diff --git a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.js b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.js deleted file mode 100644 index 27f055c5e4b..00000000000 --- a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.js +++ /dev/null @@ -1,367 +0,0 @@ -// 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. - -/** - * @fileoverview 'settings-clear-browsing-data-dialog-tabs' allows the user to - * delete browsing data that has been cached by Chromium. - * - * This file is a fork of clear_browsing_data_dialog.js until the new CBD UI is - * launched. - */ -Polymer({ - is: 'settings-clear-browsing-data-dialog-tabs', - - behaviors: [WebUIListenerBehavior, settings.RouteObserverBehavior], - - properties: { - /** - * Preferences state. - */ - prefs: { - type: Object, - notify: true, - }, - - /** - * Results of browsing data counters, keyed by the suffix of - * the corresponding data type deletion preference, as reported - * by the C++ side. - * @private {!Object<string>} - */ - counters_: { - type: Object, - // Will be filled as results are reported. - value: function() { - return {}; - } - }, - - /** - * List of options for the dropdown menu. - * @private {!DropdownMenuOptionList} - */ - clearFromOptions_: { - readOnly: true, - type: Array, - value: [ - {value: 0, name: loadTimeData.getString('clearPeriodHour')}, - {value: 1, name: loadTimeData.getString('clearPeriod24Hours')}, - {value: 2, name: loadTimeData.getString('clearPeriod7Days')}, - {value: 3, name: loadTimeData.getString('clearPeriod4Weeks')}, - {value: 4, name: loadTimeData.getString('clearPeriodEverything')}, - ], - }, - - /** @private */ - clearingInProgress_: { - type: Boolean, - value: false, - }, - - /** @private */ - isSupervised_: { - type: Boolean, - value: function() { - return loadTimeData.getBoolean('isSupervised'); - }, - }, - - /** @private */ - showHistoryDeletionDialog_: { - type: Boolean, - value: false, - }, - - /** @private */ - isSignedIn_: { - type: Boolean, - value: false, - }, - - /** @private */ - isSyncingHistory_: { - type: Boolean, - value: false, - }, - - /** @private {!Array<ImportantSite>} */ - importantSites_: { - type: Array, - value: function() { - return []; - } - }, - - /** @private */ - importantSitesFlagEnabled_: { - type: Boolean, - value: function() { - return loadTimeData.getBoolean('importantSitesInCbd'); - }, - }, - - /** @private */ - showImportantSitesDialog_: { - type: Boolean, - value: false, - }, - - /** @private */ - showImportantSitesCacheSubtitle_: { - type: Boolean, - value: false, - }, - - /** - * Time in ms, when the dialog was opened. - * @private - */ - dialogOpenedTime_: { - type: Number, - value: 0, - } - }, - - /** @private {settings.ClearBrowsingDataBrowserProxy} */ - browserProxy_: null, - - /** @override */ - ready: function() { - this.addWebUIListener( - 'update-sync-state', this.updateSyncState_.bind(this)); - this.addWebUIListener( - 'update-counter-text', this.updateCounterText_.bind(this)); - }, - - /** @override */ - attached: function() { - this.browserProxy_ = - settings.ClearBrowsingDataBrowserProxyImpl.getInstance(); - this.dialogOpenedTime_ = Date.now(); - this.browserProxy_.initialize().then(() => { - this.$.clearBrowsingDataDialog.showModal(); - }); - - if (this.importantSitesFlagEnabled_) { - this.browserProxy_.getImportantSites().then(sites => { - this.importantSites_ = sites; - }); - } - }, - - /** - * Record visits to the CBD dialog. - * - * settings.RouteObserverBehavior - * @param {!settings.Route} currentRoute - * @protected - */ - currentRouteChanged: function(currentRoute) { - if (currentRoute == settings.routes.CLEAR_BROWSER_DATA) { - chrome.metricsPrivate.recordUserAction('ClearBrowsingData_DialogCreated'); - this.dialogOpenedTime_ = Date.now(); - } - }, - - /** - * Updates the history description to show the relevant information - * depending on sync and signin state. - * - * @param {boolean} signedIn Whether the user is signed in. - * @param {boolean} syncing Whether the user is syncing history. - * @param {boolean} otherFormsOfBrowsingHistory Whether the user has other - * forms of browsing history in their account. - * @private - */ - updateSyncState_: function(signedIn, syncing, otherFormsOfBrowsingHistory) { - this.isSignedIn_ = signedIn; - this.isSyncingHistory_ = syncing; - this.$.clearBrowsingDataDialog.classList.add('fully-rendered'); - }, - - /** - * Choose a summary checkbox label. - * @param {boolean} isSignedIn - * @param {boolean} isSyncingHistory - * @param {string} historySummary - * @param {string} historySummarySigned - * @param {string} historySummarySynced - * @return {string} - * @private - */ - browsingCheckboxLabel_: function( - isSignedIn, isSyncingHistory, historySummary, historySummarySigned, - historySummarySynced) { - if (isSyncingHistory) { - return historySummarySynced; - } else if (isSignedIn) { - return historySummarySigned; - } - return historySummary; - }, - - /** - * Choose a content/site settings label. - * @param {string} siteSettings - * @param {string} contentSettings - * @return {string} - * @private - */ - siteSettingsLabel_: function(siteSettings, contentSettings) { - return loadTimeData.getBoolean('enableSiteSettings') ? siteSettings : - contentSettings; - }, - - /** - * Updates the text of a browsing data counter corresponding to the given - * preference. - * @param {string} prefName Browsing data type deletion preference. - * @param {string} text The text with which to update the counter - * @private - */ - updateCounterText_: function(prefName, text) { - // Data type deletion preferences are named "browser.clear_data.<datatype>". - // Strip the common prefix, i.e. use only "<datatype>". - const matches = prefName.match(/^browser\.clear_data\.(\w+)$/); - this.set('counters_.' + assert(matches[1]), text); - }, - - /** - * @return {boolean} Whether the ImportantSites dialog should be shown. - * @private - */ - shouldShowImportantSites_: function() { - if (!this.importantSitesFlagEnabled_) - return false; - const tab = this.$.tabs.selectedItem; - if (!tab.querySelector('.cookies-checkbox').checked) { - return false; - } - - const haveImportantSites = this.importantSites_.length > 0; - chrome.send( - 'metricsHandler:recordBooleanHistogram', - ['History.ClearBrowsingData.ImportantDialogShown', haveImportantSites]); - return haveImportantSites; - }, - - /** - * Handles the tap on the Clear Data button. - * @private - */ - onClearBrowsingDataTap_: function() { - if (this.shouldShowImportantSites_()) { - const tab = this.$.tabs.selectedItem; - this.showImportantSitesDialog_ = true; - this.showImportantSitesCacheSubtitle_ = - tab.querySelector('.cache-checkbox').checked; - this.$.clearBrowsingDataDialog.close(); - // Show important sites dialog after dom-if is applied. - this.async(() => this.$$('#importantSitesDialog').showModal()); - } else { - this.clearBrowsingData_(); - } - }, - - /** - * Handles closing of the clear browsing data dialog. Stops the close - * event from propagating if another dialog is shown to prevent the - * privacy-page from closing this dialog. - * @private - */ - onClearBrowsingDataDialogClose_: function(event) { - if (this.showImportantSitesDialog_) - event.stopPropagation(); - }, - - /** - * Clears browsing data and maybe shows a history notice. - * @private - */ - clearBrowsingData_: function() { - this.clearingInProgress_ = true; - const tab = this.$.tabs.selectedItem; - - checkboxes = tab.querySelectorAll('settings-checkbox'); - const dataTypes = []; - checkboxes.forEach((checkbox) => { - if (checkbox.checked) - dataTypes.push(checkbox.pref.key); - }); - - const timePeriod = tab.querySelector('.time-range-select').pref.value; - - if (tab.id == 'basic-tab') { - chrome.metricsPrivate.recordUserAction('ClearBrowsingData_BasicTab'); - } else { - chrome.metricsPrivate.recordUserAction('ClearBrowsingData_AdvancedTab'); - } - - this.browserProxy_ - .clearBrowsingData(dataTypes, timePeriod, this.importantSites_) - .then(shouldShowNotice => { - this.clearingInProgress_ = false; - this.showHistoryDeletionDialog_ = shouldShowNotice; - chrome.metricsPrivate.recordMediumTime( - 'History.ClearBrowsingData.TimeSpentInDialog', - Date.now() - this.dialogOpenedTime_); - if (!shouldShowNotice) - this.closeDialogs_(); - }); - }, - - /** - * Closes the clear browsing data or important site dialog if they are open. - * @private - */ - closeDialogs_: function() { - if (this.$.clearBrowsingDataDialog.open) - this.$.clearBrowsingDataDialog.close(); - if (this.showImportantSitesDialog_) - this.$$('#importantSitesDialog').close(); - }, - - /** @private */ - onCancelTap_: function() { - this.$.clearBrowsingDataDialog.cancel(); - }, - - /** - * Handles the tap confirm button in important sites. - * @private - */ - onImportantSitesConfirmTap_: function() { - this.clearBrowsingData_(); - }, - - /** @private */ - onImportantSitesCancelTap_: function() { - /** @type {!CrDialogElement} */ (this.$$('#importantSitesDialog')).cancel(); - }, - - /** - * Handles the closing of the notice about other forms of browsing history. - * @private - */ - onHistoryDeletionDialogClose_: function() { - this.showHistoryDeletionDialog_ = false; - this.closeDialogs_(); - }, - - /** - * Records an action when the user changes between the basic and advanced tab. - * @param {!Event} event - * @private - */ - recordTabChange_: function(event) { - if (event.detail.value == 0) { - chrome.metricsPrivate.recordUserAction( - 'ClearBrowsingData_SwitchTo_BasicTab'); - } else { - chrome.metricsPrivate.recordUserAction( - 'ClearBrowsingData_SwitchTo_AdvancedTab'); - } - }, - -}); diff --git a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/compiled_resources2.gyp b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/compiled_resources2.gyp index 518e48ef804..8660449e98a 100644 --- a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/compiled_resources2.gyp @@ -14,6 +14,7 @@ { 'target_name': 'clear_browsing_data_dialog', 'dependencies': [ + '<(DEPTH)/third_party/polymer/v1_0/components-chromium/iron-pages/compiled_resources2.gyp:iron-pages-extracted', '<(DEPTH)/third_party/polymer/v1_0/components-chromium/iron-resizable-behavior/compiled_resources2.gyp:iron-resizable-behavior-extracted', '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:load_time_data', diff --git a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/history_deletion_dialog.html b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/history_deletion_dialog.html index a17572fb569..52429899184 100644 --- a/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/history_deletion_dialog.html +++ b/chromium/chrome/browser/resources/settings/clear_browsing_data_dialog/history_deletion_dialog.html @@ -11,7 +11,7 @@ <div slot="title">$i18n{historyDeletionDialogTitle}</div> <div slot="body">$i18nRaw{historyDeletionDialogBody}</div> <div slot="button-container"> - <paper-button class="action-button" on-tap="onOkTap_"> + <paper-button class="action-button" on-click="onOkTap_"> $i18n{historyDeletionDialogOK} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/compiled_resources2.gyp b/chromium/chrome/browser/resources/settings/compiled_resources2.gyp index a0f14ce056a..f56f7ebe8c4 100644 --- a/chromium/chrome/browser/resources/settings/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/settings/compiled_resources2.gyp @@ -57,6 +57,7 @@ 'target_name': 'search_settings', 'dependencies': [ '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:search_highlight_utils', ], 'includes': ['../../../../third_party/closure_compiler/compile_js2.gypi'], }, @@ -79,6 +80,7 @@ 'default_browser_page/compiled_resources2.gyp:*', 'device_page/compiled_resources2.gyp:*', 'downloads_page/compiled_resources2.gyp:*', + 'incompatible_applications_page/compiled_resources2.gyp:*', 'internet_page/compiled_resources2.gyp:*', 'languages_page/compiled_resources2.gyp:*', 'on_startup_page/compiled_resources2.gyp:*', diff --git a/chromium/chrome/browser/resources/settings/controls/controlled_button.html b/chromium/chrome/browser/resources/settings/controls/controlled_button.html index e0134790c7d..a5cf488854f 100644 --- a/chromium/chrome/browser/resources/settings/controls/controlled_button.html +++ b/chromium/chrome/browser/resources/settings/controls/controlled_button.html @@ -49,7 +49,7 @@ <paper-button disabled="[[enforced_]]">[[label]]</paper-button> <template is="dom-if" if="[[hasPrefPolicyIndicator(pref.*)]]" restamp> - <cr-policy-pref-indicator pref="[[pref]]" on-tap="onIndicatorTap_" + <cr-policy-pref-indicator pref="[[pref]]" on-click="onIndicatorTap_" icon-aria-label="[[label]]"> </cr-policy-pref-indicator> </template> diff --git a/chromium/chrome/browser/resources/settings/controls/controlled_button.js b/chromium/chrome/browser/resources/settings/controls/controlled_button.js index 307475678a9..49d46860349 100644 --- a/chromium/chrome/browser/resources/settings/controls/controlled_button.js +++ b/chromium/chrome/browser/resources/settings/controls/controlled_button.js @@ -32,7 +32,7 @@ Polymer({ * @private */ onIndicatorTap_: function(e) { - // Disallow <controlled-button on-tap="..."> when controlled. + // Disallow <controlled-button on-click="..."> when controlled. e.preventDefault(); e.stopPropagation(); }, diff --git a/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.html b/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.html index 10b1bca7fe2..d6fcdf26b06 100644 --- a/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.html +++ b/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.html @@ -12,7 +12,7 @@ :host { --ink-to-circle: calc((var(--paper-radio-button-ink-size) - var(--paper-radio-button-size)) / 2); - @apply(--settings-actionable); + @apply --settings-actionable; align-items: center; display: flex; outline: none; @@ -24,7 +24,7 @@ } #label { - color: var(--paper-radio-button-label-color, --primary-text-color); + color: var(--paper-radio-button-label-color, var(--primary-text-color)); } .circle, @@ -53,11 +53,12 @@ .circle { border: 2px solid var(--paper-radio-button-unchecked-color, - --primary-text-color); + var(--primary-text-color)); } :host([checked]) .circle { - border-color: var(--paper-radio-button-checked-color, --primary-color); + border-color: var(--paper-radio-button-checked-color, + var(--primary-color)); } .disc { @@ -69,23 +70,23 @@ :host([checked]) .disc { background-color: var(--paper-radio-button-checked-color, - --primary-color); + var(--primary-color)); transform: scale(0.5); } paper-ripple { color: var(--paper-radio-button-unchecked-ink-color, - --primary-text-color); + var(--primary-text-color)); opacity: .6; } :host([checked]) paper-ripple { color: var(--paper-radio-button-checked-ink-color, - --primary-text-color); + var(--primary-text-color)); } :host(:not([controlled_])) { - @apply(--settings-actionable); + @apply --settings-actionable; } :host([controlled_]) { @@ -100,12 +101,12 @@ :host([controlled_]) .circle { border-color: var(--paper-radio-button-unchecked-color, - --primary-text-color); + var(--primary-text-color)); } :host([controlled_][checked]) .disc { background-color: var(--paper-radio-button-unchecked-color, - --primary-text-color); + var(--primary-text-color)); } :host([controlled_]) #labelWrapper { @@ -132,7 +133,7 @@ </div> <template is="dom-if" if="[[showIndicator_(controlled_, name, pref.*)]]"> - <cr-policy-pref-indicator pref="[[pref]]" on-tap="onIndicatorTap_" + <cr-policy-pref-indicator pref="[[pref]]" on-click="onIndicatorTap_" icon-aria-label="[[label]]"> </cr-policy-pref-indicator> </template> diff --git a/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.js b/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.js index ca55d6b2bb1..53bd0505a3c 100644 --- a/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.js +++ b/chromium/chrome/browser/resources/settings/controls/controlled_radio_button.js @@ -48,7 +48,6 @@ Polymer({ 'blur': 'updatePressed_', 'down': 'updatePressed_', 'focus': 'updatePressed_', - 'tap': 'onTap_', 'up': 'updatePressed_', }, @@ -88,17 +87,11 @@ Polymer({ * @private */ onIndicatorTap_: function(e) { - // Disallow <controlled-radio-button on-tap="..."> when controlled. + // Disallow <controlled-radio-button on-click="..."> when controlled. e.preventDefault(); e.stopPropagation(); }, - /** @private */ - onTap_: function() { - if (!this.controlled_) - this.checked = true; - }, - /** * @param {!Event} e * @private diff --git a/chromium/chrome/browser/resources/settings/controls/extension_controlled_indicator.html b/chromium/chrome/browser/resources/settings/controls/extension_controlled_indicator.html index ff53538c98f..50e3313c33c 100644 --- a/chromium/chrome/browser/resources/settings/controls/extension_controlled_indicator.html +++ b/chromium/chrome/browser/resources/settings/controls/extension_controlled_indicator.html @@ -17,7 +17,7 @@ } img { - @apply(--cr-icon-height-width); + @apply --cr-icon-height-width; -webkit-margin-end: 16px; } @@ -32,7 +32,7 @@ <img role="presentation" src="chrome://extension-icon/[[extensionId]]/40/1"> <span inner-h-t-m-l="[[getLabel_(extensionId, extensionName)]]"></span> <template is="dom-if" if="[[extensionCanBeDisabled]]" restamp> - <paper-button class="secondary-button" on-tap="onDisableTap_"> + <paper-button class="secondary-button" on-click="onDisableTap_"> $i18n{disable} </paper-button> </template> diff --git a/chromium/chrome/browser/resources/settings/controls/important_site_checkbox.html b/chromium/chrome/browser/resources/settings/controls/important_site_checkbox.html index 0a0dd1b1937..e70da80b8f5 100644 --- a/chromium/chrome/browser/resources/settings/controls/important_site_checkbox.html +++ b/chromium/chrome/browser/resources/settings/controls/important_site_checkbox.html @@ -20,7 +20,7 @@ } paper-checkbox:not([checked]) .secondary { - @apply(--settings-secondary-unchecked); + @apply --settings-secondary-unchecked; } .middot { @@ -28,7 +28,7 @@ } .label { - @apply(--settings-checkbox-label); + @apply --settings-checkbox-label; } </style> <div id="outerRow"> diff --git a/chromium/chrome/browser/resources/settings/controls/settings_checkbox.html b/chromium/chrome/browser/resources/settings/controls/settings_checkbox.html index b46ee716595..0563536cb09 100644 --- a/chromium/chrome/browser/resources/settings/controls/settings_checkbox.html +++ b/chromium/chrome/browser/resources/settings/controls/settings_checkbox.html @@ -30,7 +30,7 @@ } paper-checkbox:not([checked]) .secondary { - @apply(--settings-secondary-unchecked); + @apply --settings-secondary-unchecked; } cr-policy-pref-indicator { @@ -38,7 +38,7 @@ } .label { - @apply(--settings-checkbox-label); + @apply --settings-checkbox-label; } </style> <div id="outerRow" noSubLabel$="[[!hasSubLabel_(subLabel, subLabelHtml)]]"> diff --git a/chromium/chrome/browser/resources/settings/controls/settings_checkbox.js b/chromium/chrome/browser/resources/settings/controls/settings_checkbox.js index 3c0e8ded868..c4482b15c39 100644 --- a/chromium/chrome/browser/resources/settings/controls/settings_checkbox.js +++ b/chromium/chrome/browser/resources/settings/controls/settings_checkbox.js @@ -33,7 +33,7 @@ Polymer({ subLabelHtmlChanged_: function() { const links = this.root.querySelectorAll('.secondary.label a'); links.forEach((link) => { - link.addEventListener('tap', this.stopPropagation); + link.addEventListener('click', this.stopPropagation); }); }, diff --git a/chromium/chrome/browser/resources/settings/controls/settings_toggle_button.html b/chromium/chrome/browser/resources/settings/controls/settings_toggle_button.html index 7cdb86fdff3..2277abc6f7e 100644 --- a/chromium/chrome/browser/resources/settings/controls/settings_toggle_button.html +++ b/chromium/chrome/browser/resources/settings/controls/settings_toggle_button.html @@ -1,5 +1,6 @@ <link rel="import" href="chrome://resources/html/polymer.html"> +<link rel="import" href="chrome://resources/cr_elements/shared_vars_css.html"> <link rel="import" href="chrome://resources/cr_elements/cr_toggle/cr_toggle.html"> <link rel="import" href="chrome://resources/cr_elements/policy/cr_policy_pref_indicator.html"> <link rel="import" href="chrome://resources/polymer/v1_0/iron-flex-layout/iron-flex-layout-classes.html"> @@ -10,7 +11,7 @@ <template> <style include="settings-shared iron-flex"> :host { - @apply(--cr-section); + @apply --cr-section; } :host(.first), @@ -29,7 +30,7 @@ } :host([elide-label]) .label { - @apply(--settings-text-elide); + @apply --cr-text-elide; } #outerRow { diff --git a/chromium/chrome/browser/resources/settings/date_time_page/date_time_page.html b/chromium/chrome/browser/resources/settings/date_time_page/date_time_page.html index c064fabcbef..20b846cb2e7 100644 --- a/chromium/chrome/browser/resources/settings/date_time_page/date_time_page.html +++ b/chromium/chrome/browser/resources/settings/date_time_page/date_time_page.html @@ -45,7 +45,7 @@ if="[[prefs.cros.flags.fine_grained_time_zone_detection_enabled.value]]" restamp> <div id="timeZoneSettingsTrigger" class="settings-box first" - on-tap="onTimeZoneSettings_" actionable> + on-click="onTimeZoneSettings_" actionable> <div id="timeZoneButton" class="two-line"> $i18n{timeZoneButton} <div class="secondary"> @@ -79,7 +79,7 @@ label="$i18n{use24HourClock}"> </settings-toggle-button> <div class="settings-box" id="setDateTime" actionable - on-tap="onSetDateTimeTap_" hidden$="[[!canSetDateTime_]]"> + on-click="onSetDateTimeTap_" hidden$="[[!canSetDateTime_]]"> <div class="start">$i18n{setDateTime}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{setDateTime}"></button> diff --git a/chromium/chrome/browser/resources/settings/default_browser_page/default_browser_page.html b/chromium/chrome/browser/resources/settings/default_browser_page/default_browser_page.html index ee383626bcc..c24783b0bd2 100644 --- a/chromium/chrome/browser/resources/settings/default_browser_page/default_browser_page.html +++ b/chromium/chrome/browser/resources/settings/default_browser_page/default_browser_page.html @@ -17,7 +17,7 @@ </div> <div class="separator"></div> <paper-button class="secondary-button" - on-tap="onSetDefaultBrowserTap_"> + on-click="onSetDefaultBrowserTap_"> $i18n{defaultBrowserMakeDefaultButton} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/device_page/device_page.html b/chromium/chrome/browser/resources/settings/device_page/device_page.html index 56cc4fcfd76..cf90e9489fb 100644 --- a/chromium/chrome/browser/resources/settings/device_page/device_page.html +++ b/chromium/chrome/browser/resources/settings/device_page/device_page.html @@ -24,7 +24,7 @@ focus-config="[[focusConfig_]]"> <neon-animatable id="main" route-path="default"> <div id="pointersRow" class="settings-box first" - on-tap="onPointersTap_" actionable> + on-click="onPointersTap_" actionable> <div class="start"> [[getPointersTitle_(hasMouse_, hasTouchpad_)]] </div> @@ -32,34 +32,34 @@ aria-label$="[[getPointersTitle_(hasMouse_, hasTouchpad_)]]"></button> </div> - <div id="keyboardRow" class="settings-box" on-tap="onKeyboardTap_" + <div id="keyboardRow" class="settings-box" on-click="onKeyboardTap_" actionable> <div class="start">$i18n{keyboardTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{keyboardTitle}"></button> </div> <template is="dom-if" if="[[hasStylus_]]"> - <div id="stylusRow" class="settings-box" on-tap="onStylusTap_" + <div id="stylusRow" class="settings-box" on-click="onStylusTap_" actionable> <div class="start">$i18n{stylusTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{stylusTitle}"></button> </div> </template> - <div id="displayRow" class="settings-box" on-tap="onDisplayTap_" + <div id="displayRow" class="settings-box" on-click="onDisplayTap_" actionable> <div class="start">$i18n{displayTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{displayTitle}"></button> </div> - <div id="storageRow" class="settings-box" on-tap="onStorageTap_" + <div id="storageRow" class="settings-box" on-click="onStorageTap_" actionable> <div class="start">$i18n{storageTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{storageTitle}"></button> </div> <template is="dom-if" if="[[enablePowerSettings_]]"> - <div id="powerRow" class="settings-box" on-tap="onPowerTap_" + <div id="powerRow" class="settings-box" on-click="onPowerTap_" actionable> <div class="start">$i18n{powerTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" diff --git a/chromium/chrome/browser/resources/settings/device_page/device_page_browser_proxy.js b/chromium/chrome/browser/resources/settings/device_page/device_page_browser_proxy.js index 0344e145e0b..ae2fb6d008c 100644 --- a/chromium/chrome/browser/resources/settings/device_page/device_page_browser_proxy.js +++ b/chromium/chrome/browser/resources/settings/device_page/device_page_browser_proxy.js @@ -185,7 +185,7 @@ cr.define('settings', function() { /** override */ handleLinkEvent(e) { - // Prevent the link from activating its parent element when tapped or + // Prevent the link from activating its parent element when clicked or // when Enter is pressed. if (e.type != 'keydown' || e.keyCode == 13) e.stopPropagation(); diff --git a/chromium/chrome/browser/resources/settings/device_page/display.html b/chromium/chrome/browser/resources/settings/device_page/display.html index 81cf27e5056..0eec56f0f97 100644 --- a/chromium/chrome/browser/resources/settings/device_page/display.html +++ b/chromium/chrome/browser/resources/settings/device_page/display.html @@ -82,7 +82,7 @@ restamp> <div class="secondary self-start"> <paper-checkbox checked="[[isMirrored_(displays)]]" - on-tap="onMirroredTap_" + on-click="onMirroredTap_" aria-label="[[getDisplayMirrorText_(displays)]]"> <div class="text-area">[[getDisplayMirrorText_(displays)]]</div> </paper-checkbox> @@ -188,7 +188,7 @@ <button is="cr-link-row" icon-class="subpage-arrow" class="indented hr" id="overscan" label="$i18n{displayOverscanPageTitle}" - sub-label="$i18n{displayOverscanPageText}" on-tap="onOverscanTap_" + sub-label="$i18n{displayOverscanPageText}" on-click="onOverscanTap_" hidden$="[[!showOverscanSetting_(selectedDisplay)]]"> </button> @@ -198,7 +198,7 @@ </settings-display-overscan-dialog> <div class="settings-box indented two-line" - on-tap="onTouchCalibrationTap_" + on-click="onTouchCalibrationTap_" hidden$="[[!showTouchCalibrationSetting_(selectedDisplay)]]" actionable> <div class="start"> diff --git a/chromium/chrome/browser/resources/settings/device_page/display.js b/chromium/chrome/browser/resources/settings/device_page/display.js index c21864b443b..2fcb479af3b 100644 --- a/chromium/chrome/browser/resources/settings/device_page/display.js +++ b/chromium/chrome/browser/resources/settings/device_page/display.js @@ -699,25 +699,18 @@ Polymer({ // Blur the control so that when the transition animation completes and the // UI is focused, the control does not receive focus. crbug.com/785070 event.target.blur(); - let id = ''; - /** @type {!chrome.system.display.DisplayProperties} */ - const properties = {}; - if (this.isMirrored_(this.displays)) { - id = this.primaryDisplayId; - properties.mirroringSourceId = ''; - } else { - // Set the mirroringSourceId of the secondary (first non-primary) display. - for (let i = 0; i < this.displays.length; ++i) { - const display = this.displays[i]; - if (display.id != this.primaryDisplayId) { - id = display.id; - break; - } - } - properties.mirroringSourceId = this.primaryDisplayId; - } - settings.display.systemDisplayApi.setDisplayProperties( - id, properties, this.setPropertiesCallback_.bind(this)); + + /** @type {!chrome.system.display.MirrorModeInfo} */ + let mirrorModeInfo = { + mode: this.isMirrored_(this.displays) ? + chrome.system.display.MirrorMode.OFF : + chrome.system.display.MirrorMode.NORMAL + }; + settings.display.systemDisplayApi.setMirrorMode(mirrorModeInfo, () => { + let error = chrome.runtime.lastError; + if (error) + console.error('setMirrorMode Error: ' + error.message); + }); }, /** @private */ diff --git a/chromium/chrome/browser/resources/settings/device_page/display_layout.html b/chromium/chrome/browser/resources/settings/device_page/display_layout.html index 289ebf9c8f2..4b138cb2b17 100644 --- a/chromium/chrome/browser/resources/settings/device_page/display_layout.html +++ b/chromium/chrome/browser/resources/settings/device_page/display_layout.html @@ -54,7 +54,7 @@ } .display.elevate { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; } </style> <div id="displayArea" on-iron-resize="calculateVisualScale_"> @@ -67,15 +67,11 @@ </template> <template is="dom-repeat" items="[[displays]]"> <div id="_[[item.id]]" class="display elevate" - draggable="[[dragEnabled]]" on-tap="onSelectDisplayTap_" + draggable="[[dragEnabled]]" on-click="onSelectDisplayTap_" style$="[[getDivStyle_(item.id, item.bounds, visualScale)]]" selected$="[[isSelected_(item, selectedDisplay)]]"> - <div hidden$="[[mirroring]]"> - [[item.name]] - </div> - <div hidden$="[[!mirroring]]"> - $i18n{displayMirrorDisplayName} - </div> + [[getDisplayName_(mirroring, item.name, + '$i18nPolymer{displayMirrorDisplayName}')]] </div> </template> </div> diff --git a/chromium/chrome/browser/resources/settings/device_page/display_layout.js b/chromium/chrome/browser/resources/settings/device_page/display_layout.js index 4b9f4192931..6cd70ca9581 100644 --- a/chromium/chrome/browser/resources/settings/device_page/display_layout.js +++ b/chromium/chrome/browser/resources/settings/device_page/display_layout.js @@ -191,6 +191,17 @@ Polymer({ }, /** + * @param {boolean} mirroring + * @param {string} displayName + * @param {string} mirroringName + * @return {string} + * @private + */ + getDisplayName_: function(mirroring, displayName, mirroringName) { + return mirroring ? mirroringName : displayName; + }, + + /** * @param {!chrome.system.display.DisplayUnitInfo} display * @param {!chrome.system.display.DisplayUnitInfo} selectedDisplay * @return {boolean} diff --git a/chromium/chrome/browser/resources/settings/device_page/display_overscan_dialog.html b/chromium/chrome/browser/resources/settings/device_page/display_overscan_dialog.html index 08e263c7738..5c90de1c152 100644 --- a/chromium/chrome/browser/resources/settings/device_page/display_overscan_dialog.html +++ b/chromium/chrome/browser/resources/settings/device_page/display_overscan_dialog.html @@ -74,10 +74,10 @@ </div> </div> <div slot="button-container"> - <paper-button id="reset" class="cancel-button" on-tap="onResetTap_"> + <paper-button id="reset" class="cancel-button" on-click="onResetTap_"> $i18n{displayOverscanReset} </paper-button> - <paper-button class="action-button" on-tap="onSaveTap_"> + <paper-button class="action-button" on-click="onSaveTap_"> $i18n{ok} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/device_page/drive_cache_dialog.html b/chromium/chrome/browser/resources/settings/device_page/drive_cache_dialog.html index 696cbad5190..d111fcf73a3 100644 --- a/chromium/chrome/browser/resources/settings/device_page/drive_cache_dialog.html +++ b/chromium/chrome/browser/resources/settings/device_page/drive_cache_dialog.html @@ -17,11 +17,11 @@ </div> <div slot="button-container"> <paper-button id="cancelButton" class="cancel-button" - on-tap="onCancelTap_"> + on-click="onCancelTap_"> $i18n{cancel} </paper-button> <paper-button id="deleteButton" class="action-button" - on-tap="onDeleteTap_"> + on-click="onDeleteTap_"> $i18n{storageDeleteAllButtonTitle} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/device_page/keyboard.html b/chromium/chrome/browser/resources/settings/device_page/keyboard.html index cabf3ca8e43..1ec10018922 100644 --- a/chromium/chrome/browser/resources/settings/device_page/keyboard.html +++ b/chromium/chrome/browser/resources/settings/device_page/keyboard.html @@ -104,12 +104,12 @@ </div> </iron-collapse> <div id="keyboardOverlay" class="settings-box" - on-tap="onShowKeyboardShortcutsOverlayTap_" actionable> + on-click="onShowKeyboardShortcutsOverlayTap_" actionable> <div class="start">$i18n{showKeyboardShortcutsOverlay}</div> <button class="icon-external" is="paper-icon-button-light" aria-label="$i18n{showKeyboardShortcutsOverlay}"></button> </div> - <div class="settings-box" on-tap="onShowLanguageInputTap_" actionable> + <div class="settings-box" on-click="onShowLanguageInputTap_" actionable> <div class="start">$i18n{keyboardShowLanguageAndInput}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{keyboardShowLanguageAndInput}"></button> diff --git a/chromium/chrome/browser/resources/settings/device_page/pointers.html b/chromium/chrome/browser/resources/settings/device_page/pointers.html index d93db637cf5..0fb133e2933 100644 --- a/chromium/chrome/browser/resources/settings/device_page/pointers.html +++ b/chromium/chrome/browser/resources/settings/device_page/pointers.html @@ -85,7 +85,7 @@ <paper-radio-button name="true"> $i18n{naturalScrollLabel} <a href="$i18n{naturalScrollLearnMoreLink}" target="_blank" - on-tap="onLearnMoreLinkActivated_" + on-click="onLearnMoreLinkActivated_" on-keydown="onLearnMoreLinkActivated_"> $i18n{naturalScrollLearnMore} </a> diff --git a/chromium/chrome/browser/resources/settings/device_page/storage.html b/chromium/chrome/browser/resources/settings/device_page/storage.html index a10225b2f15..fc8cc1df590 100644 --- a/chromium/chrome/browser/resources/settings/device_page/storage.html +++ b/chromium/chrome/browser/resources/settings/device_page/storage.html @@ -195,7 +195,7 @@ </div> </div> </div> - <div class="settings-box two-line" on-tap="onDownloadsTap_" actionable> + <div class="settings-box two-line" on-click="onDownloadsTap_" actionable> <div class="start"> $i18n{storageItemDownloads} <div id="downloadsSize" class="secondary"> @@ -207,7 +207,7 @@ aria-describedby="downloadsSize"></button> </div> <template is="dom-if" if="[[driveEnabled_]]"> - <div class="settings-box two-line" on-tap="onDriveCacheTap_" + <div class="settings-box two-line" on-click="onDriveCacheTap_" actionable$="[[hasDriveCache_]]" > <div class="start"> $i18n{storageItemDriveCache} @@ -221,7 +221,7 @@ </button> </div> </template> - <div class="settings-box two-line" on-tap="onBrowsingDataTap_" actionable> + <div class="settings-box two-line" on-click="onBrowsingDataTap_" actionable> <div class="start"> $i18n{storageItemBrowsingData} <div id="browsingDataSize" class="secondary"> @@ -233,7 +233,7 @@ aria-describedby="browsingDataSize"></button> </div> <template is="dom-if" if="[[androidEnabled_]]"> - <div class="settings-box two-line" on-tap="onAndroidTap_" actionable> + <div class="settings-box two-line" on-click="onAndroidTap_" actionable> <div class="start"> $i18n{storageItemAndroid} <div id="androidSize" class="secondary"> @@ -246,7 +246,7 @@ </div> </template> <template is="dom-if" if="[[!isGuest_]]"> - <div class="settings-box two-line" on-tap="onOtherUsersTap_" actionable> + <div class="settings-box two-line" on-click="onOtherUsersTap_" actionable> <div class="start"> $i18n{storageItemOtherUsers} <div id="otherUsersSize" class="secondary"> diff --git a/chromium/chrome/browser/resources/settings/device_page/stylus.html b/chromium/chrome/browser/resources/settings/device_page/stylus.html index 9aa157eceab..93252074b04 100644 --- a/chromium/chrome/browser/resources/settings/device_page/stylus.html +++ b/chromium/chrome/browser/resources/settings/device_page/stylus.html @@ -19,7 +19,7 @@ paper-spinner-lite { margin-left: 12px; - @apply(--cr-icon-height-width); + @apply --cr-icon-height-width; } cr-policy-indicator { @@ -77,7 +77,7 @@ <!-- TODO(scottchen): Make a proper a[href].settings-box with icon-external (see: https://crbug.com/684005)--> - <div class="settings-box two-line" on-tap="onFindAppsTap_" actionable + <div class="settings-box two-line" on-click="onFindAppsTap_" actionable hidden$="[[!prefs.arc.enabled.value]]"> <div class="start"> $i18n{stylusFindMoreAppsPrimary} @@ -97,7 +97,7 @@ <div class="settings-box first"> <div id="lock-screen-toggle-label" class="start" actionable$="[[!disallowedOnLockScreenByPolicy_(selectedApp_)]]" - on-tap="toggleLockScreenSupport_"> + on-click="toggleLockScreenSupport_"> $i18n{stylusNoteTakingAppEnabledOnLockScreen} </div> <template is="dom-if" diff --git a/chromium/chrome/browser/resources/settings/downloads_page/downloads_page.html b/chromium/chrome/browser/resources/settings/downloads_page/downloads_page.html index 2f213e62e6a..ea4a199cb0a 100644 --- a/chromium/chrome/browser/resources/settings/downloads_page/downloads_page.html +++ b/chromium/chrome/browser/resources/settings/downloads_page/downloads_page.html @@ -29,7 +29,7 @@ <div class="separator"></div> <controlled-button class="secondary-button" id="changeDownloadsPath" label="$i18n{changeDownloadLocation}" - on-tap="selectDownloadLocation_" + on-click="selectDownloadLocation_" pref="[[prefs.download.default_directory]]" end-justified> </controlled-button> @@ -52,7 +52,7 @@ </div> <div class="separator"></div> <paper-button id="resetAutoOpenFileTypes" class="secondary-button" - on-tap="onClearAutoOpenFileTypesTap_"> + on-click="onClearAutoOpenFileTypesTap_"> $i18n{clear} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/google_assistant_page/google_assistant_page.html b/chromium/chrome/browser/resources/settings/google_assistant_page/google_assistant_page.html index b96f029582e..46081160c54 100644 --- a/chromium/chrome/browser/resources/settings/google_assistant_page/google_assistant_page.html +++ b/chromium/chrome/browser/resources/settings/google_assistant_page/google_assistant_page.html @@ -30,7 +30,7 @@ on-change="onGoogleAssistantContextEnableChange_"> </settings-toggle-button> <div id="googleAssistantSettings" class="settings-box" - on-tap="onGoogleAssistantSettingsTapped_" actionable> + on-click="onGoogleAssistantSettingsTapped_" actionable> <div class="start"> $i18n{googleAssistantSettings} </div> diff --git a/chromium/chrome/browser/resources/settings/icons.html b/chromium/chrome/browser/resources/settings/icons.html index c15dd7ba4a7..bf9a0008176 100644 --- a/chromium/chrome/browser/resources/settings/icons.html +++ b/chromium/chrome/browser/resources/settings/icons.html @@ -10,9 +10,7 @@ List icons here rather than importing large sets of (e.g. Polymer) icons. <defs> <!-- Ads icon in the Content Settings --> <g id="ads"> - <path d="M19,3H5C3.89,3,3,3.9,3,5v14c0,1.1,0.89,2,2,2h14c1.1,0,2-0.9,2-2V5C21,3.9,20.1,3,19,3z M11,15H9.5v-1.5h-2V15H6v-4.5V9h1.5h2H10h1V15z M18,14c0,0.55-0.45,1-1,1h-4V9h4c0.55,0,1,0.45,1,1V14z"></path> - <rect x="7.5" y="10.5" width="2" height="1.5"></rect> - <rect x="14.5" y="10.5" width="2" height="3"></rect> + <path d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.89-2-2-2zm0 14H5V8h14v10z"></path> </g> <!-- Cookie SVG obtained from rolfe@ --> @@ -104,6 +102,7 @@ List icons here rather than importing large sets of (e.g. Polymer) icons. <g id="refresh"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"></path></g> <g id="restore"><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"></path></g> <g id="rotate-right"><path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"></path></g> + <g id="sensors"><path d="M10 8.5c-0.8 0-1.5 0.7-1.5 1.5s0.7 1.5 1.5 1.5s1.5-0.7 1.5-1.5S10.8 8.5 10 8.5z M7.6 5.8 C6.2 6.7 5.2 8.2 5.2 10c0 1.8 1 3.4 2.4 4.2l0.8-1.4c-1-0.6-1.6-1.6-1.6-2.8c0-1.2 0.6-2.2 1.6-2.8L7.6 5.8z M14.8 10 c0-1.8-1-3.4-2.4-4.2l-0.8 1.4c0.9 0.6 1.6 1.6 1.6 2.8c0 1.2-0.6 2.2-1.6 2.8l0.8 1.4C13.8 13.4 14.8 11.8 14.8 10z M6 3 c-2.4 1.4-4 4-4 7c0 3 1.6 5.6 4 7l0.8-1.4c-1.9-1.1-3.2-3.2-3.2-5.6c0-2.4 1.3-4.5 3.2-5.6L6 3z M13.2 4.4 c1.9 1.1 3.2 3.2 3.2 5.6c0 2.4-1.3 4.5-3.2 5.6L14 17c2.4-1.4 4-4 4-7c0-3-1.6-5.6-4-7L13.2 4.4z"></path></g> <g id="security"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"></path></g> <if expr="chromeos"> <g id="alert-device-out-of-range" fill="none" fill-rule="evenodd"><path d="M-1-1h20v20H-1z"></path><path fill="#C53929" fill-rule="nonzero" d="M8.167 11.5h1.666v1.667H8.167V11.5zm0-6.667h1.666v5H8.167v-5zM8.992.667C4.392.667.667 4.4.667 9s3.725 8.333 8.325 8.333c4.608 0 8.341-3.733 8.341-8.333S13.6.667 8.992.667zm.008 15A6.665 6.665 0 0 1 2.333 9 6.665 6.665 0 0 1 9 2.333 6.665 6.665 0 0 1 15.667 9 6.665 6.665 0 0 1 9 15.667z"></path></g> diff --git a/chromium/chrome/browser/resources/settings/images/sync_banner.svg b/chromium/chrome/browser/resources/settings/images/sync_banner.svg new file mode 100644 index 00000000000..d2ecb8603a7 --- /dev/null +++ b/chromium/chrome/browser/resources/settings/images/sync_banner.svg @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="155px" height="131px" viewBox="0 0 155 131" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <polygon fill="#3874D5" points="0 114.5 154.75 114.5 154.75 0 0 0"></polygon> + <g transform="translate(61.000000, 53.250000)"> + <path d="M59.25,77 L19,77 C17.35,77 16,75.65 16,74 L16,3 C16,1.35 17.35,0 19,0 L59.25,0 C60.9,0 62.25,1.35 62.25,3 L62.25,74 C62.25,75.65 60.9,77 59.25,77" fill="#4285F4"></path> + <path d="M31.4473,61.5527 C31.4473,70.0837 24.5313,76.9997 16.0003,76.9997 C7.4683,76.9997 0.5523,70.0837 0.5523,61.5527 C0.5523,53.0207 7.4683,46.1047 16.0003,46.1047 C24.5313,46.1047 31.4473,53.0207 31.4473,61.5527" fill="#FABB05"></path> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/settings/incompatible_applications_page/compiled_resources2.gyp b/chromium/chrome/browser/resources/settings/incompatible_applications_page/compiled_resources2.gyp new file mode 100644 index 00000000000..c73f9ebe50a --- /dev/null +++ b/chromium/chrome/browser/resources/settings/incompatible_applications_page/compiled_resources2.gyp @@ -0,0 +1,33 @@ +# Copyright 2018 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. +{ + 'targets': [ + { + 'target_name': 'incompatible_applications_browser_proxy', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:cr', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'incompatible_applications_page', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:assert', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:web_ui_listener_behavior', + 'incompatible_applications_browser_proxy', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + { + 'target_name': 'incompatible_application_item', + 'dependencies': [ + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:assert', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:i18n_behavior', + 'incompatible_applications_browser_proxy', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, + ], +} diff --git a/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_application_item.html b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_application_item.html new file mode 100644 index 00000000000..1f8027ae747 --- /dev/null +++ b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_application_item.html @@ -0,0 +1,25 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/html/assert.html"> +<link rel="import" href="chrome://resources/html/i18n_behavior.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> +<link rel="import" href="../settings_shared_css.html"> +<link rel="import" href="incompatible_applications_browser_proxy.html"> + +<dom-module id="incompatible-application-item"> + <template> + <style include="settings-shared"> + :host { + display: block; + } + </style> + <div class="list-item"> + <div class="start">[[applicationName]]</div> + <div class="separator"></div> + <paper-button class="primary-button" on-click="onActionTap_"> + [[getActionName_(actionType)]] + </paper-button> + </div> + </template> + <script src="incompatible_application_item.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_application_item.js b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_application_item.js new file mode 100644 index 00000000000..72dd7f1941e --- /dev/null +++ b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_application_item.js @@ -0,0 +1,103 @@ +// Copyright 2018 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. + +/** + * @fileoverview + * 'incompatible-application-item' represents one item in a "list-box" of + * incompatible applications, as defined in + * chrome/browser/conflicts/problematic_programs_updater_win.h. + * This element contains a button that can be used to remove or update the + * incompatible application, depending on the value of the action-type property. + * + * Example usage: + * + * <div class="list-box"> + * <incompatible-application-item + * application-name="Google Chrome" + * action-type="1" + * action-url="https://www.google.com/chrome/more-info"> + * </incompatible-application-item> + * </div> + * + * or + * + * <div class="list-box"> + * <template is="dom-repeat" items="[[applications]]" as="application"> + * <incompatible-application-item + * application-name="[[application.name]]" + * action-type="[[application.actionType]]" + * action-url="[[application.actionUrl]]"> + * </incompatible-application-item> + * </template> + * </div> + */ + +Polymer({ + is: 'incompatible-application-item', + + behaviors: [I18nBehavior], + + properties: { + /** + * The name of the application to be displayed. Also used for the UNINSTALL + * action, where the name is passed to the startProgramUninstallation() + * call. + */ + applicationName: String, + + /** + * The type of the action to be taken on this incompatible application. Must + * be one of BlacklistMessageType in + * chrome/browser/conflicts/proto/module_list.proto. + * @type {!settings.ActionTypes} + */ + actionType: Number, + + /** + * For the actions MORE_INFO and UPGRADE, this is the URL that must be + * opened when the action button is tapped. + */ + actionUrl: String, + }, + + /** @private {settings.IncompatibleApplicationsBrowserProxy} */ + browserProxy_: null, + + /** @override */ + created: function() { + this.browserProxy_ = + settings.IncompatibleApplicationsBrowserProxyImpl.getInstance(); + }, + + /** + * Executes the action for this incompatible application, depending on + * actionType. + * @private + */ + onActionTap_: function() { + if (this.actionType === settings.ActionTypes.UNINSTALL) { + this.browserProxy_.startProgramUninstallation(this.applicationName); + } else if ( + this.actionType === settings.ActionTypes.MORE_INFO || + this.actionType === settings.ActionTypes.UPGRADE) { + this.browserProxy_.openURL(this.actionUrl); + } else { + assertNotReached(); + } + }, + + /** + * @return {string} The label that should be applied to the action button. + * @private + */ + getActionName_: function(actionType) { + if (actionType === settings.ActionTypes.UNINSTALL) + return this.i18n('incompatibleApplicationsRemoveButton'); + if (actionType === settings.ActionTypes.MORE_INFO) + return this.i18n('learnMore'); + if (actionType === settings.ActionTypes.UPGRADE) + return this.i18n('incompatibleApplicationsUpdateButton'); + assertNotReached(); + }, +}); diff --git a/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_browser_proxy.html b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_browser_proxy.html new file mode 100644 index 00000000000..8d55a232daa --- /dev/null +++ b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_browser_proxy.html @@ -0,0 +1,2 @@ +<link rel="import" href="chrome://resources/html/cr.html"> +<script src="incompatible_applications_browser_proxy.js"></script> diff --git a/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_browser_proxy.js b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_browser_proxy.js new file mode 100644 index 00000000000..7017e0a469d --- /dev/null +++ b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_browser_proxy.js @@ -0,0 +1,122 @@ +// Copyright 2018 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. + +/** + * @fileoverview A helper object used from the Incompatible Applications section + * to interact with the browser. + */ + +cr.exportPath('settings'); + +/** + * All possible actions to take on an incompatible application. + * + * Must be kept in sync with BlacklistMessageType in + * chrome/browser/conflicts/proto/module_list.proto + * @readonly + * @enum {number} + */ +settings.ActionTypes = { + UNINSTALL: 0, + MORE_INFO: 1, + UPGRADE: 2, +}; + +/** + * @typedef {{ + * name: string, + * actionType: {settings.ActionTypes}, + * actionUrl: string, + * }} + */ +settings.IncompatibleApplication; + +cr.define('settings', function() { + /** @interface */ + class IncompatibleApplicationsBrowserProxy { + /** + * Get the list of incompatible applications. + * @return {!Promise<!Array<!settings.IncompatibleApplication>>} + */ + requestIncompatibleApplicationsList() {} + + /** + * Launches the Apps & Features page that allows uninstalling 'programName'. + * @param {string} programName + */ + startProgramUninstallation(programName) {} + + /** + * Opens the specified URL in a new tab. + * @param {!string} url + */ + openURL(url) {} + + /** + * Requests the plural string for the subtitle of the Incompatible + * Applications subpage. + * @param {number} numApplications + * @return {!Promise<string>} + */ + getSubtitlePluralString(numApplications) {} + + /** + * Requests the plural string for the subtitle of the Incompatible + * Applications subpage, when the user does not have administrator rights. + * @param {number} numApplications + * @return {!Promise<string>} + */ + getSubtitleNoAdminRightsPluralString(numApplications) {} + + /** + * Requests the plural string for the title of the list of Incompatible + * Applications. + * @param {number} numApplications + * @return {!Promise<string>} + */ + getListTitlePluralString(numApplications) {} + } + + /** @implements {settings.IncompatibleApplicationsBrowserProxy} */ + class IncompatibleApplicationsBrowserProxyImpl { + /** @override */ + requestIncompatibleApplicationsList() { + return cr.sendWithPromise('requestIncompatibleApplicationsList'); + } + + /** @override */ + startProgramUninstallation(programName) { + chrome.send('startProgramUninstallation', [programName]); + } + + /** @override */ + openURL(url) { + window.open(url); + } + + /** @override */ + getSubtitlePluralString(numApplications) { + return cr.sendWithPromise('getSubtitlePluralString', numApplications); + } + + /** @override */ + getSubtitleNoAdminRightsPluralString(numApplications) { + return cr.sendWithPromise( + 'getSubtitleNoAdminRightsPluralString', numApplications); + } + + /** @override */ + getListTitlePluralString(numApplications) { + return cr.sendWithPromise('getListTitlePluralString', numApplications); + } + } + + cr.addSingletonGetter(IncompatibleApplicationsBrowserProxyImpl); + + return { + IncompatibleApplicationsBrowserProxy: IncompatibleApplicationsBrowserProxy, + IncompatibleApplicationsBrowserProxyImpl: + IncompatibleApplicationsBrowserProxyImpl, + }; +}); diff --git a/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_page.html b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_page.html new file mode 100644 index 00000000000..9fcccb14ef3 --- /dev/null +++ b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_page.html @@ -0,0 +1,59 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/html/assert.html"> +<link rel="import" href="chrome://resources/html/i18n_behavior.html"> +<link rel="import" href="chrome://resources/html/web_ui_listener_behavior.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/iron-icon/iron-icon.html"> +<link rel="import" href="../settings_shared_css.html"> +<link rel="import" href="incompatible_application_item.html"> +<link rel="import" href="incompatible_applications_browser_proxy.html"> + +<dom-module id="settings-incompatible-applications-page"> + <template> + <style include="settings-shared"> + #is-done-section > iron-icon { + --iron-icon-fill-color: var(--google-blue-500); + } + </style> + + <div hidden$="[[!isDone_]]" id="is-done-section" class="settings-box first"> + <iron-icon icon="settings:check-circle"></iron-icon> + <div class="middle no-min-width"> + $i18n{incompatibleApplicationsDone} + </div> + </div> + + <template is="dom-if" if="[[!isDone_]]"> + <div class="settings-box first two-line"> + <iron-icon icon="settings:security"></iron-icon> + <div class="middle no-min-width"> + <div hidden$="[[!hasAdminRights_]]"> + [[subtitleText_]] $i18nRaw{incompatibleApplicationsSubpageLearnHow} + </div> + <div hidden$="[[hasAdminRights_]]"> + [[subtitleNoAdminRightsText_]] + </div> + </div> + </div> + <div class="settings-box continuation"> + <div class="secondary">[[listTitleText_]]</div> + </div> + <div id="incompatible-applications-list" class="list-frame vertical-list"> + <template is="dom-repeat" items="[[applications_]]" as="application"> + <incompatible-application-item + hidden$="[[!hasAdminRights_]]" + class="incompatible-application" + application-name="[[application.name]]" + action-type="[[application.type]]" + action-url="[[application.url]]"> + </incompatible-application-item> + <div hidden$="[[hasAdminRights_]]" + class="list-item incompatible-application"> + [[application.name]] + </div> + </template> + </div> + </template> + </template> + <script src="incompatible_applications_page.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_page.js b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_page.js new file mode 100644 index 00000000000..27426d236a8 --- /dev/null +++ b/chromium/chrome/browser/resources/settings/incompatible_applications_page/incompatible_applications_page.js @@ -0,0 +1,138 @@ +// Copyright 2018 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. + +/** + * @fileoverview + * 'settings-incompatible-applications-page' is the settings subpage containing + * the list of incompatible applications. + * + * Example: + * + * <iron-animated-pages> + * <settings-incompatible-applications-page"> + * </settings-incompatible-applications-page> + * ... other pages ... + * </iron-animated-pages> + */ + +Polymer({ + is: 'settings-incompatible-applications-page', + + behaviors: [I18nBehavior, WebUIListenerBehavior], + + properties: { + /** + * Indicates if the current user has administrator rights. + * @private + */ + hasAdminRights_: { + type: Boolean, + value: function() { + return loadTimeData.getBoolean('hasAdminRights'); + }, + }, + + /** + * The list of all the incompatible applications. + * @private {Array<settings.IncompatibleApplication>} + */ + applications_: Array, + + /** + * Determines if the user has finished with this page. + * @private + */ + isDone_: { + type: Boolean, + computed: 'computeIsDone_(applications_.*)', + }, + + /** + * The text for the subtitle of the subpage. + * @private + */ + subtitleText_: { + type: String, + value: '', + }, + + /** + * The text for the subtitle of the subpage, when the user does not have + * administrator rights. + * @private + */ + subtitleNoAdminRightsText_: { + type: String, + value: '', + }, + + /** + * The text for the title of the list of incompatible applications. + * @private + */ + listTitleText_: { + type: String, + value: '', + }, + }, + + /** @override */ + ready: function() { + this.addWebUIListener( + 'incompatible-application-removed', + this.onIncompatibleApplicationRemoved_.bind(this)); + + settings.IncompatibleApplicationsBrowserProxyImpl.getInstance() + .requestIncompatibleApplicationsList() + .then(list => { + this.applications_ = list; + this.updatePluralStrings_(); + }); + }, + + /** + * @return {boolean} + * @private + */ + computeIsDone_: function() { + return this.applications_.length === 0; + }, + + /** + * Removes a single incompatible application from the |applications_| list. + * @private + */ + onIncompatibleApplicationRemoved_: function(applicationName) { + // Find the index of the element. + let index = this.applications_.findIndex(function(application) { + return application.name == applicationName; + }); + + assert(index !== -1); + + this.splice('applications_', index, 1); + }, + + /** + * Updates the texts of the Incompatible Applications subpage that depends on + * the length of |applications_|. + * @private + */ + updatePluralStrings_: function() { + const browserProxy = + settings.IncompatibleApplicationsBrowserProxyImpl.getInstance(); + const numApplications = this.applications_.length; + Promise + .all([ + browserProxy.getSubtitlePluralString(numApplications), + browserProxy.getSubtitleNoAdminRightsPluralString(numApplications), + browserProxy.getListTitlePluralString(numApplications), + ]) + .then(strings => { + this.subtitleText_ = strings[0]; + this.subtitleNoAdminRightsText_ = strings[1]; + this.listTitleText_ = strings[2]; + }); + }, +}); diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_config.html b/chromium/chrome/browser/resources/settings/internet_page/internet_config.html index 4fa0a79d5e8..fce0c7e3d29 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_config.html +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_config.html @@ -32,7 +32,7 @@ share-allow-enable="[[shareAllowEnable_]]" share-default="[[shareDefault_]]" error="{{error_}}" - on-close="close"> + on-close="onClose_"> </network-config> </div> @@ -40,17 +40,17 @@ <template is="dom-if" if="[[error_]]" restamp> <div class="flex error">[[getError_(error_)]]</div> </template> - <paper-button class="cancel-button" on-tap="onCancelTap_"> + <paper-button class="cancel-button" on-click="onCancelTap_"> $i18n{cancel} </paper-button> - <template is="dom-if" if="[[isConfigured_(networkProperties_, guid)]]"> - <paper-button class="action-button" on-tap="onSaveOrConnectTap_" + <template is="dom-if" if="[[!showConnect]]"> + <paper-button class="action-button" on-click="onSaveTap_" disabled="[[!enableSave_]]"> $i18n{save} </paper-button> </template> - <template is="dom-if" if="[[!isConfigured_(networkProperties_, guid)]]"> - <paper-button class="action-button" on-tap="onSaveOrConnectTap_" + <template is="dom-if" if="[[showConnect]]"> + <paper-button class="action-button" on-click="onConnectTap_" disabled="[[!enableConnect_]]"> $i18n{networkButtonConnect} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_config.js b/chromium/chrome/browser/resources/settings/internet_page/internet_config.js index b6bda81171f..ba7c90de4ab 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_config.js +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_config.js @@ -56,6 +56,12 @@ Polymer({ */ name: String, + /** + * Set to true to show the 'connect' button instead of 'save'. + * @private + */ + showConnect: Boolean, + /** @private */ enableConnect_: Boolean, @@ -103,6 +109,16 @@ Polymer({ }, /** + * @param {!Event} event + * @private + */ + onClose_: function(event) { + this.close(); + this.fire('networks-changed'); + event.stopPropagation(); + }, + + /** * @return {string} * @private */ @@ -124,22 +140,18 @@ Polymer({ return this.i18n('networkErrorUnknown'); }, - /** - * @return {boolean} - * @private - */ - isConfigured_: function() { - const source = this.networkProperties_.Source; - return !!this.guid && !!source && source != CrOnc.Source.NONE; - }, - /** @private */ onCancelTap_: function() { this.close(); }, /** @private */ - onSaveOrConnectTap_: function() { - this.$.networkConfig.saveOrConnect(); + onSaveTap_: function() { + this.$.networkConfig.save(); + }, + + /** @private */ + onConnectTap_: function() { + this.$.networkConfig.connect(); }, }); diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.html b/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.html index e3afc520289..cbee01f9b6e 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.html +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.html @@ -81,30 +81,30 @@ </template> </div> <template is="dom-if" if="[[!isSecondaryUser_]]"> - <paper-button on-tap="onForgetTap_" + <paper-button on-click="onForgetTap_" hidden$="[[!showForget_(networkProperties)]]"> $i18n{networkButtonForget} </paper-button> - <paper-button on-tap="onViewAccountTap_" + <paper-button on-click="onViewAccountTap_" hidden$="[[!showViewAccount_(networkProperties)]]"> $i18n{networkButtonViewAccount} </paper-button> - <paper-button on-tap="onActivateTap_" + <paper-button on-click="onActivateTap_" hidden$="[[!showActivate_(networkProperties)]]"> $i18n{networkButtonActivate} </paper-button> - <paper-button on-tap="onConfigureTap_" + <paper-button on-click="onConfigureTap_" hidden$="[[!showConfigure_(networkProperties, globalPolicy)]]"> $i18n{networkButtonConfigure} </paper-button> </template> - <paper-button class="primary-button" on-tap="onConnectTap_" + <paper-button class="primary-button" on-click="onConnectTap_" hidden$="[[!showConnect_(networkProperties, globalPolicy)]]" disabled="[[!enableConnect_(networkProperties, defaultNetwork, globalPolicy, networkPropertiesReceived_, outOfRange_)]]"> $i18n{networkButtonConnect} </paper-button> - <paper-button class="primary-button" on-tap="onDisconnectTap_" + <paper-button class="primary-button" on-click="onDisconnectTap_" hidden$="[[!showDisconnect_(networkProperties)]]"> $i18n{networkButtonDisconnect} </paper-button> @@ -203,7 +203,7 @@ <template is="dom-if" if="[[showAdvanced_(networkProperties)]]"> <!-- Advanced toggle. --> - <div class="settings-box" actionable on-tap="toggleAdvancedExpanded_"> + <div class="settings-box" actionable on-click="toggleAdvancedExpanded_"> <div class="flex">$i18n{networkSectionAdvanced}</div> <cr-expand-button expanded="{{advancedExpanded_}}" alt="$i18n{networkSectionAdvancedA11yLabel}"> @@ -232,7 +232,7 @@ <template is="dom-if" if="[[hasNetworkSection_(networkProperties)]]"> <!-- Network toggle --> - <div class="settings-box" actionable on-tap="toggleNetworkExpanded_"> + <div class="settings-box" actionable on-click="toggleNetworkExpanded_"> <div class="start">$i18n{networkSectionNetwork}</div> <cr-expand-button expanded="{{networkExpanded_}}" alt="$i18n{networkSectionNetworkExpandA11yLabel}"> @@ -274,7 +274,7 @@ <template is="dom-if" if="[[hasProxySection_(networkProperties)]]"> <!-- Proxy toggle --> - <div class="settings-box" actionable on-tap="toggleProxyExpanded_"> + <div class="settings-box" actionable on-click="toggleProxyExpanded_"> <div class="start">$i18n{networkSectionProxy}</div> <cr-expand-button expanded="{{proxyExpanded_}}" alt="$i18n{networkSectionProxyExpandA11yLabel}"> diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.js b/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.js index b8c3480ddcd..c21b36319f5 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.js +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_detail_page.js @@ -630,7 +630,6 @@ Polymer({ this.showTetherDialog_(); return; } - this.fire('network-connect', {networkProperties: this.networkProperties}); }, diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_known_networks_page.html b/chromium/chrome/browser/resources/settings/internet_page/internet_known_networks_page.html index 6d2f1357614..4466b23e664 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_known_networks_page.html +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_known_networks_page.html @@ -36,12 +36,12 @@ </cr-policy-indicator> </template> <button class="subpage-arrow" is="paper-icon-button-light" - actionable on-tap="fireShowDetails_" tabindex$="[[tabindex]]" + actionable on-click="fireShowDetails_" tabindex$="[[tabindex]]" aria-label$="[[item.Name]]"> </button> <div class="separator"></div> <button is="paper-icon-button-light" class="icon-more-vert" - preferred tabindex$="[[tabindex]]" on-tap="onMenuButtonTap_" + preferred tabindex$="[[tabindex]]" on-click="onMenuButtonTap_" title="$i18n{moreActions}"> </button> </div> @@ -63,12 +63,12 @@ </cr-policy-indicator> </template> <button class="subpage-arrow" is="paper-icon-button-light" - actionable on-tap="fireShowDetails_" tabindex$="[[tabindex]]" + actionable on-click="fireShowDetails_" tabindex$="[[tabindex]]" aria-label$="[[item.Name]]"> </button> <div class="separator"></div> <button is="paper-icon-button-light" class="icon-more-vert" - tabindex$="[[tabindex]]" on-tap="onMenuButtonTap_" + tabindex$="[[tabindex]]" on-click="onMenuButtonTap_" title="$i18n{moreActions}"> </button> </div> @@ -76,16 +76,16 @@ </div> <dialog id="dotsMenu" is="cr-action-menu"> - <button class="dropdown-item" hidden="[[!showAddPreferred_]]" - on-tap="onAddPreferredTap_"> + <button slot="item" class="dropdown-item" hidden="[[!showAddPreferred_]]" + on-click="onAddPreferredTap_"> $i18n{knownNetworksMenuAddPreferred} </button> - <button class="dropdown-item" hidden="[[!showRemovePreferred_]]" - on-tap="onRemovePreferredTap_"> + <button slot="item" class="dropdown-item" + hidden="[[!showRemovePreferred_]]" on-click="onRemovePreferredTap_"> $i18n{knownNetworksMenuRemovePreferred} </button> - <button class="dropdown-item" disabled="[[!enableForget_]]" - on-tap="onForgetTap_"> + <button slot="item" class="dropdown-item" disabled="[[!enableForget_]]" + on-click="onForgetTap_"> $i18n{knownNetworksMenuForget} </button> </dialog> diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_page.html b/chromium/chrome/browser/resources/settings/internet_page/internet_page.html index cd6b2afbadb..0c7cf9c5c72 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_page.html +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_page.html @@ -38,7 +38,7 @@ </network-summary> <template is="dom-if" if="[[allowAddConnection_(globalPolicy_)]]"> <div actionable class="settings-box two-line" - on-tap="onExpandAddConnectionsTap_"> + on-click="onExpandAddConnectionsTap_"> <div class="start layout horizontal center"> <div>$i18n{internetAddConnection}</div> </div> @@ -50,7 +50,7 @@ <div class="list-frame vertical-list"> <template is="dom-if" if="[[deviceIsEnabled_(deviceStates.WiFi)]]"> - <div actionable class="list-item" on-tap="onAddWiFiTap_"> + <div actionable class="list-item" on-click="onAddWiFiTap_"> <div class="start">$i18n{internetAddWiFi}</div> <button class$="[[getAddNetworkClass_('WiFi')]]" is="paper-icon-button-light" @@ -58,7 +58,7 @@ </button> </div> </template> - <div actionable class="list-item" on-tap="onAddVPNTap_"> + <div actionable class="list-item" on-click="onAddVPNTap_"> <div class="start">$i18n{internetAddVPN}</div> <button class$="[[getAddNetworkClass_('VPN')]]" is="paper-icon-button-light" @@ -67,7 +67,7 @@ </div> <template is="dom-repeat" items="[[thirdPartyVpnProviders_]]"> <div actionable class="list-item" - on-tap="onAddThirdPartyVpnTap_" provider="[[item]]"> + on-click="onAddThirdPartyVpnTap_" provider="[[item]]"> <div class="start">[[getAddThirdPartyVpnLabel_(item)]]</div> <button class="icon-external" is="paper-icon-button-light" aria-label$="[[getAddThirdPartyVpnLabel_(item)]]"> @@ -76,7 +76,7 @@ </template> <template is="dom-if" if="[[arcVpnProviders_.length]]"> <div actionable class="list-item" id="addArcVpn" - on-tap="onAddArcVpnTap_"> + on-click="onAddArcVpnTap_"> <div class="start">$i18n{internetAddArcVPN}</div> <button class="icon-external" is="paper-icon-button-light" aria-label$="$i18n{internetAddArcVPN}"> diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_page.js b/chromium/chrome/browser/resources/settings/internet_page/internet_page.js index 3b8126607c7..ed1eda816ef 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_page.js +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_page.js @@ -272,27 +272,27 @@ Polymer({ */ onShowConfig_: function(event) { const properties = event.detail; + let configAndConnect = !properties.GUID; // New configuration this.showConfig_( - properties.Type, properties.GUID, CrOnc.getNetworkName(properties)); + configAndConnect, properties.Type, properties.GUID, + CrOnc.getNetworkName(properties)); }, /** + * @param {boolean} configAndConnect * @param {string} type * @param {string=} guid * @param {string=} name * @private */ - showConfig_: function(type, guid, name) { - if (!loadTimeData.getBoolean('networkSettingsConfig')) { - chrome.send('configureNetwork', [guid]); - return; - } + showConfig_: function(configAndConnect, type, guid, name) { const configDialog = /** @type {!InternetConfigElement} */ (this.$.configDialog); configDialog.type = /** @type {chrome.networkingPrivate.NetworkType} */ (type); configDialog.guid = guid || ''; configDialog.name = name || ''; + configDialog.showConnect = configAndConnect; configDialog.open(); }, @@ -369,7 +369,7 @@ Polymer({ }, /** - * Event triggered when the 'Add connections' div is tapped. + * Event triggered when the 'Add connections' div is clicked. * @param {!Event} event * @private */ @@ -382,7 +382,7 @@ Polymer({ /** @private */ onAddWiFiTap_: function() { if (loadTimeData.getBoolean('networkSettingsConfig')) - this.showConfig_(CrOnc.Type.WI_FI); + this.showConfig_(true /* configAndConnect */, CrOnc.Type.WI_FI); else chrome.send('addNetwork', [CrOnc.Type.WI_FI]); }, @@ -390,7 +390,7 @@ Polymer({ /** @private */ onAddVPNTap_: function() { if (loadTimeData.getBoolean('networkSettingsConfig')) - this.showConfig_(CrOnc.Type.VPN); + this.showConfig_(true /* configAndConnect */, CrOnc.Type.VPN); else chrome.send('addNetwork', [CrOnc.Type.VPN]); }, @@ -604,7 +604,8 @@ Polymer({ } if (properties.Connectable === false || properties.ErrorState) { - this.showConfig_(properties.Type, properties.GUID, name); + this.showConfig_( + true /* configAndConnect */, properties.Type, properties.GUID, name); return; } @@ -618,7 +619,9 @@ Polymer({ console.error( 'networkingPrivate.startConnect error: ' + message + ' For: ' + properties.GUID); - this.showConfig_(properties.Type, properties.GUID, name); + this.showConfig_( + true /* configAndConnect */, properties.Type, properties.GUID, + name); } }); }, diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.html b/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.html index 3119ac4ec1c..ad9e3d48712 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.html +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.html @@ -65,12 +65,12 @@ } #gmscore-notifications-device-string { - @apply(--cr-secondary-text); + @apply --cr-secondary-text; margin-top: 5px; } #gmscore-notifications-instructions { - @apply(--cr-secondary-text); + @apply --cr-secondary-text; -webkit-padding-start: 15px; margin: 0; } @@ -86,19 +86,19 @@ <button is="paper-icon-button-light" id="addButton" hidden$="[[!showAddButton_(deviceState, globalPolicy)]]" aria-label="$i18n{internetAddWiFi}" class="icon-add-wifi" - on-tap="onAddButtonTap_" tabindex$="[[tabindex]]"> + on-click="onAddButtonTap_" tabindex$="[[tabindex]]"> </button> <paper-toggle-button id="deviceEnabledButton" aria-label$="[[getToggleA11yString_(deviceState)]]" checked="[[deviceIsEnabled_(deviceState)]]" disabled="[[!enableToggleIsEnabled_(deviceState)]]" - on-tap="onDeviceEnabledTap_"> + on-click="onDeviceEnabledTap_"> </paper-toggle-button> </div> </template> <template is="dom-if" if="[[knownNetworksIsVisible_(deviceState)]]"> - <div actionable class="settings-box" on-tap="onKnownNetworksTap_"> + <div actionable class="settings-box" on-click="onKnownNetworksTap_"> <div class="start">$i18n{knownNetworksButton}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{knownNetworksButton}"> @@ -114,7 +114,7 @@ <div class="flex">$i18n{networkVpnBuiltin}</div> <button is="paper-icon-button-light" class="icon-add-circle" aria-label="$i18n{internetAddVPN}" - on-tap="onAddButtonTap_" tabindex$="[[tabindex]]"> + on-click="onAddButtonTap_" tabindex$="[[tabindex]]"> </button> </div> </template> @@ -143,6 +143,7 @@ <li>$i18n{gmscoreNotificationsFirstStep}</li> <li>$i18n{gmscoreNotificationsSecondStep}</li> <li>$i18n{gmscoreNotificationsThirdStep}</li> + <li>$i18n{gmscoreNotificationsFourthStep}</li> </ol> </div> </template> @@ -165,7 +166,7 @@ <div class="flex">[[item.ProviderName]]</div> <button is="paper-icon-button-light" class="icon-add-circle" aria-label$="[[getAddThirdPartyVpnA11yString_(item)]]" - on-tap="onAddThirdPartyVpnTap_" tabindex$="[[tabindex]]"> + on-click="onAddThirdPartyVpnTap_" tabindex$="[[tabindex]]"> </button> </div> <cr-network-list show-buttons @@ -185,7 +186,7 @@ <div class="flex">[[item.ProviderName]]</div> <button is="paper-icon-button-light" class="icon-add-circle" aria-label$="[[getAddArcVpnAllyString_(item)]]" - on-tap="onAddArcVpnTap_" tabindex$="[[tabindex]]"> + on-click="onAddArcVpnTap_" tabindex$="[[tabindex]]"> </button> </div> <cr-network-list show-buttons @@ -204,7 +205,7 @@ <template is="dom-if" if="[[tetherToggleIsVisible_(deviceState, tetherDeviceState)]]"> <div class="settings-box two-line" actionable - on-tap="onTetherEnabledTap_"> + on-click="onTetherEnabledTap_"> <div class="start"> $i18n{internetToggleTetherLabel} <div id="tetherSecondary" class="secondary"> diff --git a/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.js b/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.js index 2d1c08ee297..c45566055aa 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.js +++ b/chromium/chrome/browser/resources/settings/internet_page/internet_subpage.js @@ -442,7 +442,7 @@ Polymer({ }, /** - * Event triggered when the known networks button is tapped. + * Event triggered when the known networks button is clicked. * @private */ onKnownNetworksTap_: function() { diff --git a/chromium/chrome/browser/resources/settings/internet_page/network_proxy_section.html b/chromium/chrome/browser/resources/settings/internet_page/network_proxy_section.html index f5d88e89c41..7aa2e646067 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/network_proxy_section.html +++ b/chromium/chrome/browser/resources/settings/internet_page/network_proxy_section.html @@ -90,11 +90,11 @@ </div> <div slot="button-container"> <paper-button class="cancel-button" - on-tap="onAllowSharedDialogCancel_"> + on-click="onAllowSharedDialogCancel_"> $i18n{cancel} </paper-button> <paper-button class="action-button" - on-tap="onAllowSharedDialogConfirm_"> + on-click="onAllowSharedDialogConfirm_"> $i18n{confirm} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.html b/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.html index 812bf51fff9..2df92961938 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.html +++ b/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.html @@ -34,7 +34,7 @@ font-weight: 400; } </style> - <div actionable class="settings-box two-line" on-tap="onShowDetailsTap_"> + <div actionable class="settings-box two-line" on-click="onShowDetailsTap_"> <div id="details" no-flex$="[[showSimInfo_(deviceState)]]"> <cr-network-icon network-state="[[activeNetworkState]]" device-state="[[deviceState]]"> @@ -48,7 +48,7 @@ </div> <template is="dom-if" if="[[showSimInfo_(deviceState)]]" restamp> - <network-siminfo editable on-tap="doNothing_" + <network-siminfo editable on-click="doNothing_" network-properties="[[getCellularState_(deviceState)]]" networking-private="[[networkingPrivate]]"> </network-siminfo> @@ -57,7 +57,7 @@ <template is="dom-if" if="[[showPolicyIndicator_(activeNetworkState)]]"> <cr-policy-indicator indicator-type="[[getIndicatorTypeForSource( activeNetworkState.Source)]]" - on-tap="doNothing_"> + on-click="doNothing_"> </cr-policy-indicator> </template> @@ -75,7 +75,7 @@ aria-label$="[[getToggleA11yString_(deviceState)]]" checked="[[deviceIsEnabled_(deviceState)]]" disabled="[[!enableToggleIsEnabled_(deviceState)]]" - on-tap="onDeviceEnabledTap_"> + on-click="onDeviceEnabledTap_"> </paper-toggle-button> </template> </div> diff --git a/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.js b/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.js index 65c882d595d..e7a54cb245a 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.js +++ b/chromium/chrome/browser/resources/settings/internet_page/network_summary_item.js @@ -68,10 +68,9 @@ Polymer({ * @private */ getNetworkStateText_: function(activeNetworkState, deviceState) { - const state = activeNetworkState.ConnectionState; - const name = CrOnc.getNetworkName(activeNetworkState); - if (state) - return this.getConnectionStateText_(state, name); + const stateText = this.getConnectionStateText_(activeNetworkState); + if (stateText) + return stateText; // No network state, use device state. if (deviceState) { // Type specific scanning or initialization states. @@ -98,12 +97,15 @@ Polymer({ }, /** - * @param {CrOnc.ConnectionState} state - * @param {string} name + * @param {!CrOnc.NetworkStateProperties} networkState * @return {string} * @private */ - getConnectionStateText_: function(state, name) { + getConnectionStateText_: function(networkState) { + const state = networkState.ConnectionState; + if (!state) + return ''; + const name = CrOnc.getNetworkName(networkState); switch (state) { case CrOnc.ConnectionState.CONNECTED: return name; @@ -112,6 +114,10 @@ Polymer({ return CrOncStrings.networkListItemConnectingTo.replace('$1', name); return CrOncStrings.networkListItemConnecting; case CrOnc.ConnectionState.NOT_CONNECTED: + if (networkState.Type == CrOnc.Type.CELLULAR && networkState.Cellular && + networkState.Cellular.Scanning) { + return this.i18n('internetMobileSearching'); + } return CrOncStrings.networkListItemNotConnected; } assertNotReached(); diff --git a/chromium/chrome/browser/resources/settings/internet_page/tether_connection_dialog.html b/chromium/chrome/browser/resources/settings/internet_page/tether_connection_dialog.html index 51e02e88fb0..c54c5c1186a 100644 --- a/chromium/chrome/browser/resources/settings/internet_page/tether_connection_dialog.html +++ b/chromium/chrome/browser/resources/settings/internet_page/tether_connection_dialog.html @@ -108,11 +108,11 @@ </ul> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onNotNowTap_"> + <paper-button class="cancel-button" on-click="onNotNowTap_"> $i18n{tetherConnectionNotNowButton} </paper-button> <paper-button id="connectButton" class="action-button" - on-tap="onConnectTap_" disabled="[[outOfRange]]"> + on-click="onConnectTap_" disabled="[[outOfRange]]"> $i18n{tetherConnectionConnectButton} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/languages_page/add_languages_dialog.html b/chromium/chrome/browser/resources/settings/languages_page/add_languages_dialog.html index 6a6a1512872..a3f6edd6f54 100644 --- a/chromium/chrome/browser/resources/settings/languages_page/add_languages_dialog.html +++ b/chromium/chrome/browser/resources/settings/languages_page/add_languages_dialog.html @@ -59,10 +59,10 @@ </iron-list> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelButtonTap_"> + <paper-button class="cancel-button" on-click="onCancelButtonTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onActionButtonTap_" + <paper-button class="action-button" on-click="onActionButtonTap_" disabled="[[disableActionButton_]]"> $i18n{add} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/languages_page/edit_dictionary_page.html b/chromium/chrome/browser/resources/settings/languages_page/edit_dictionary_page.html index d5d0912c66d..ce5c803d292 100644 --- a/chromium/chrome/browser/resources/settings/languages_page/edit_dictionary_page.html +++ b/chromium/chrome/browser/resources/settings/languages_page/edit_dictionary_page.html @@ -42,7 +42,7 @@ '$i18nPolymer{addDictionaryWordDuplicateError}', '$i18nPolymer{addDictionaryWordLengthError}')]]"></paper-input> </div> - <paper-button class="secondary-button" on-tap="onAddWordTap_" + <paper-button class="secondary-button" on-click="onAddWordTap_" disabled="[[disableAddButton_(newWordValue_)]]" id="addWord"> $i18n{addDictionaryWordButton} </paper-button> @@ -58,7 +58,7 @@ <div class="list-item"> <div class="word text-elide">[[item]]</div> <button is="paper-icon-button-light" class="icon-clear" - on-tap="onRemoveWordTap_" tabindex$="[[tabIndex]]"> + on-click="onRemoveWordTap_" tabindex$="[[tabIndex]]"> </button> </div> </template> diff --git a/chromium/chrome/browser/resources/settings/languages_page/languages_page.html b/chromium/chrome/browser/resources/settings/languages_page/languages_page.html index 608022db7bb..7b70201c2cb 100644 --- a/chromium/chrome/browser/resources/settings/languages_page/languages_page.html +++ b/chromium/chrome/browser/resources/settings/languages_page/languages_page.html @@ -84,7 +84,7 @@ focus-config="[[focusConfig_]]"> <neon-animatable route-path="default"> <div class$="settings-box first [[getLanguageListTwoLine_()]]" - actionable on-tap="toggleExpandButton_"> + actionable on-click="toggleExpandButton_"> <div class="start"> <div>$i18n{languagesListTitle}</div> <if expr="chromeos or is_win"> @@ -127,20 +127,20 @@ <if expr="chromeos or is_win"> <template is="dom-if" if="[[isRestartRequired_( item.language.code, languages.prospectiveUILanguage)]]"> - <paper-button on-tap="onRestartTap_"> + <paper-button on-click="onRestartTap_"> $i18n{restart} </paper-button> </template> </if> <button is="paper-icon-button-light" title="$i18n{moreActions}" - id="more-[[item.language.code]]" on-tap="onDotsTap_" + id="more-[[item.language.code]]" on-click="onDotsTap_" class="icon-more-vert"> </button> </div> </template> <div class="list-item"> <a is="action-link" class="list-button" id="addLanguages" - on-tap="onAddLanguagesTap_"> + on-click="onAddLanguagesTap_"> $i18n{addLanguages} </a> </div> @@ -153,7 +153,7 @@ <if expr="chromeos"> <div id="manageInputMethodsSubpageTrigger" class="settings-box two-line" actionable - on-tap="toggleExpandButton_"> + on-click="toggleExpandButton_"> <div class="start"> <div>$i18n{inputMethodsListTitle}</div> <div class="secondary"> @@ -171,7 +171,7 @@ items="[[languages.inputMethods.enabled]]"> <div class$="list-item [[getInputMethodItemClass_( item.id, languages.inputMethods.currentId)]]" - on-tap="onInputMethodTap_" on-keypress="onInputMethodTap_" + on-click="onInputMethodTap_" on-keypress="onInputMethodTap_" actionable tabindex="0"> <div class="start"> <div>[[item.displayName]]</div> @@ -182,12 +182,13 @@ </div> </div> <button class="icon-external" is="paper-icon-button-light" - on-tap="onInputMethodOptionsTap_" + on-click="onInputMethodOptionsTap_" hidden="[[!item.hasOptionsPage]]"> </button> </div> </template> - <div class="list-item" on-tap="onManageInputMethodsTap_" actionable> + <div class="list-item" on-click="onManageInputMethodsTap_" + actionable> <div class="start" id="manageInputMethods"> $i18n{manageInputMethods} </div> @@ -207,7 +208,7 @@ class$="settings-box [[getSpellCheckListTwoLine_( spellCheckSecondaryText_)]]" actionable$="[[!spellCheckDisabled_]]" - on-tap="toggleExpandButton_"> + on-click="toggleExpandButton_"> <div class="start"> <div>$i18n{spellCheckListTitle}</div> <div class="secondary">[[spellCheckSecondaryText_]]</div> @@ -230,7 +231,7 @@ <template is="dom-repeat" items="[[spellCheckLanguages_]]"> <div class="list-item"> <template is="dom-if" if="[[!item.isManaged]]"> - <div class="start" on-tap="onSpellCheckChange_" + <div class="start" on-click="onSpellCheckChange_" actionable$="[[item.language.supportsSpellcheck]]"> [[item.language.displayName]] </div> @@ -250,7 +251,7 @@ </template> </div> </template> - <div class="list-item" on-tap="onEditDictionaryTap_" actionable> + <div class="list-item" on-click="onEditDictionaryTap_" actionable> <div class="start" id="customSpelling"> $i18n{manageSpellCheck} </div> @@ -265,7 +266,8 @@ <dialog is="cr-action-menu" class$="[[getMenuClass_(prefs.translate.enabled.value)]]"> <if expr="chromeos or is_win"> - <paper-checkbox id="uiLanguageItem" class="dropdown-item" + <paper-checkbox id="uiLanguageItem" slot="item" + class="dropdown-item" checked="[[isProspectiveUILanguage_( detailLanguage_.language.code, languages.prospectiveUILanguage)]]" @@ -275,7 +277,8 @@ $i18n{displayInThisLanguage} </paper-checkbox> </if> - <paper-checkbox id="offerTranslations" class="dropdown-item" + <paper-checkbox id="offerTranslations" slot="item" + class="dropdown-item" checked="[[detailLanguage_.translateEnabled]]" on-change="onTranslateCheckboxChange_" hidden="[[!prefs.translate.enabled.value]]" @@ -283,26 +286,26 @@ detailLanguage_.language, languages.translateTarget)]]"> $i18n{offerToTranslateInThisLanguage} </paper-checkbox> - <hr> - <button class="dropdown-item" role="menuitem" - on-tap="onMoveToTopTap_" + <hr slot="item"> + <button slot="item" class="dropdown-item" role="menuitem" + on-click="onMoveToTopTap_" hidden="[[isNthLanguage_( 0, detailLanguage_, languages.enabled.*)]]"> $i18n{moveToTop} </button> - <button class="dropdown-item" role="menuitem" - on-tap="onMoveUpTap_" + <button slot="item" class="dropdown-item" role="menuitem" + on-click="onMoveUpTap_" hidden="[[!showMoveUp_(detailLanguage_, languages.enabled.*)]]"> $i18n{moveUp} </button> - <button class="dropdown-item" role="menuitem" - on-tap="onMoveDownTap_" + <button slot="item" class="dropdown-item" role="menuitem" + on-click="onMoveDownTap_" hidden="[[!showMoveDown_( detailLanguage_, languages.enabled.*)]]"> $i18n{moveDown} </button> - <button class="dropdown-item" role="menuitem" - on-tap="onRemoveLanguageTap_" + <button slot="item" class="dropdown-item" role="menuitem" + on-click="onRemoveLanguageTap_" hidden="[[!detailLanguage_.removable]]"> $i18n{removeLanguage} </button> diff --git a/chromium/chrome/browser/resources/settings/languages_page/languages_page.js b/chromium/chrome/browser/resources/settings/languages_page/languages_page.js index 43787f93abb..d9df072708b 100644 --- a/chromium/chrome/browser/resources/settings/languages_page/languages_page.js +++ b/chromium/chrome/browser/resources/settings/languages_page/languages_page.js @@ -116,11 +116,13 @@ Polymer({ }, }, + // <if expr="not is_macosx"> observers: [ 'updateSpellcheckLanguages_(languages.enabled.*, ' + 'languages.forcedSpellCheckLanguages.*)', 'updateSpellcheckEnabled_(prefs.browser.enable_spellchecking.*)', ], + // </if> /** * Stamps and opens the Add Languages dialog, registering a listener to @@ -628,7 +630,7 @@ Polymer({ /** * Closes the shared action menu after a short delay, so when a checkbox is - * tapped it can be seen to change state before disappearing. + * clicked it can be seen to change state before disappearing. * @private */ closeMenuSoon_: function() { diff --git a/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.html b/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.html index 40bfadfde9d..f4bdec1ade6 100644 --- a/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.html +++ b/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.html @@ -23,13 +23,11 @@ label="$i18n{onStartupOpenNewTab}" no-extension-indicator> </controlled-radio-button> - <template is="dom-if" if="[[showIndicator_( - ntpExtension_, prefs.session.restore_on_startup.value)]]"> + <template is="dom-if" if="[[ntpExtension_]]"> <extension-controlled-indicator extension-id="[[ntpExtension_.id]]" extension-name="[[ntpExtension_.name]]" - extension-can-be-disabled="[[ntpExtension_.canBeDisabled]]" - on-extension-disable="getNtpExtension_"> + extension-can-be-disabled="[[ntpExtension_.canBeDisabled]]"> </extension-controlled-indicator> </template> <controlled-radio-button name="[[prefValues_.CONTINUE]]" diff --git a/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.js b/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.js index 26484951dcf..aff7303d079 100644 --- a/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.js +++ b/chromium/chrome/browser/resources/settings/on_startup_page/on_startup_page.js @@ -37,29 +37,13 @@ Polymer({ /** @override */ attached: function() { - this.getNtpExtension_(); - this.addWebUIListener('update-ntp-extension', ntpExtension => { + const updateNtpExtension = ntpExtension => { // Note that |ntpExtension| is empty if there is no NTP extension. this.ntpExtension_ = ntpExtension; - }); - }, - - /** @private */ - getNtpExtension_: function() { + }; settings.OnStartupBrowserProxyImpl.getInstance().getNtpExtension().then( - function(ntpExtension) { - this.ntpExtension_ = ntpExtension; - }.bind(this)); - }, - - /** - * @param {?NtpExtension} ntpExtension - * @param {number} restoreOnStartup Value of prefs.session.restore_on_startup. - * @return {boolean} - * @private - */ - showIndicator_: function(ntpExtension, restoreOnStartup) { - return !!ntpExtension && restoreOnStartup == this.prefValues_.OPEN_NEW_TAB; + updateNtpExtension); + this.addWebUIListener('update-ntp-extension', updateNtpExtension); }, /** diff --git a/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_dialog.html b/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_dialog.html index d2139335785..313bdf7aba0 100644 --- a/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_dialog.html +++ b/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_dialog.html @@ -21,10 +21,10 @@ </paper-input> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_" + <paper-button class="cancel-button" on-click="onCancelTap_" id="cancel">$i18n{cancel}</paper-button> <paper-button id="actionButton" class="action-button" - on-tap="onActionButtonTap_">[[actionButtonText_]]</paper-button> + on-click="onActionButtonTap_">[[actionButtonText_]]</paper-button> </div> </dialog> </template> diff --git a/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.html b/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.html index cf1a7a9cf81..99699c9a021 100644 --- a/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.html +++ b/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.html @@ -25,16 +25,17 @@ <div class="text-elide secondary">[[model.url]]</div> </div> <template is="dom-if" if="[[editable]]"> - <button is="paper-icon-button-light" id="dots" on-tap="onDotsTap_" + <button is="paper-icon-button-light" id="dots" on-click="onDotsTap_" title="$i18n{moreActions}" focus-row-control focus-type="menu" class="icon-more-vert"> </button> <template is="cr-lazy-render" id="menu"> <dialog is="cr-action-menu"> - <button class="dropdown-item" on-tap="onEditTap_"> + <button slot="item" class="dropdown-item" on-click="onEditTap_"> $i18n{edit} </button> - <button class="dropdown-item" id="remove" on-tap="onRemoveTap_"> + <button slot="item" class="dropdown-item" id="remove" + on-click="onRemoveTap_"> $i18n{onStartupRemove} </button> </dialog> diff --git a/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.js b/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.js index 8c4460dc865..dd6ac50a045 100644 --- a/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.js +++ b/chromium/chrome/browser/resources/settings/on_startup_page/startup_url_entry.js @@ -12,7 +12,7 @@ cr.exportPath('settings'); /** * The name of the event fired from this element when the "Edit" option is - * tapped. + * clicked. * @type {string} */ settings.EDIT_STARTUP_URL_EVENT = 'edit-startup-url'; diff --git a/chromium/chrome/browser/resources/settings/on_startup_page/startup_urls_page.html b/chromium/chrome/browser/resources/settings/on_startup_page/startup_urls_page.html index 02b87b2093a..93cd7cd0c94 100644 --- a/chromium/chrome/browser/resources/settings/on_startup_page/startup_urls_page.html +++ b/chromium/chrome/browser/resources/settings/on_startup_page/startup_urls_page.html @@ -18,7 +18,7 @@ <template> <style include="settings-shared action-link iron-flex"> .list-frame { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; } .list-frame > div { @@ -26,7 +26,7 @@ } #outer { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; max-height: 355px; /** Enough height to show six entries. */ } @@ -53,13 +53,13 @@ <template is="dom-if" if="[[shouldAllowUrlsEdit_( prefs.session.startup_urls.enforcement)]]" restamp> <div class="list-item" id="addPage"> - <a is="action-link" class="list-button" on-tap="onAddPageTap_"> + <a is="action-link" class="list-button" on-click="onAddPageTap_"> $i18n{onStartupAddNewPage} </a> </div> <div class="list-item" id="useCurrentPages"> <a is="action-link" class="list-button" - on-tap="onUseCurrentPagesTap_"> + on-click="onUseCurrentPagesTap_"> $i18n{onStartupUseCurrent} </a> </div> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/address_edit_dialog.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/address_edit_dialog.html index 37c189b5231..8d6515df3a8 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/address_edit_dialog.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/address_edit_dialog.html @@ -51,7 +51,7 @@ transform: scale(0.75); transform-origin: left; width: 133%; - @apply(--paper-input-container-label-floating); + @apply --paper-input-container-label-floating; } :host-context([dir=rtl]) #select-label { @@ -144,11 +144,11 @@ </div> <div slot="button-container"> <paper-button id="cancelButton" class="cancel-button" - on-tap="onCancelTap_"> + on-click="onCancelTap_"> $i18n{cancel} </paper-button> <paper-button id="saveButton" class="action-button" - disabled="[[!canSave_]]" on-tap="onSaveButtonTap_"> + disabled="[[!canSave_]]" on-click="onSaveButtonTap_"> $i18n{save} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/autofill_section.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/autofill_section.html index 4da90c016dc..e5193b1a7d1 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/autofill_section.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/autofill_section.html @@ -65,9 +65,9 @@ } </style> <settings-toggle-button id="autofillToggle" - class="first primary-toggle" + class="first" aria-label="$i18n{autofill}" no-extension-indicator - label="[[getOnOffLabel_(prefs.autofill.enabled.value)]]" + label="$i18n{autofillFormsLabel}" pref="{{prefs.autofill.enabled}}"> </settings-toggle-button> <template is="dom-if" if="[[prefs.autofill.enabled.extensionId]]"> @@ -85,7 +85,7 @@ <h2 class="start">$i18n{addresses}</h2> <paper-button id="addAddress" class="secondary-button header-aligned-button" - on-tap="onAddAddressTap_"> + on-click="onAddAddressTap_"> $i18n{add} </paper-button> </div> @@ -108,13 +108,13 @@ </div> <template is="dom-if" if="[[item.metadata.isLocal]]"> <button is="paper-icon-button-light" id="addressMenu" - class="icon-more-vert" on-tap="onAddressMenuTap_" + class="icon-more-vert" on-click="onAddressMenuTap_" title="$i18n{moreActions}"> </button> </template> <template is="dom-if" if="[[!item.metadata.isLocal]]"> <button is="paper-icon-button-light" class="icon-external" - on-tap="onRemoteEditAddressTap_" actionable></button> + on-click="onRemoteEditAddressTap_" actionable></button> </template> </div> </template> @@ -125,10 +125,10 @@ </div> </div> <dialog is="cr-action-menu" id="addressSharedMenu"> - <button id="menuEditAddress" class="dropdown-item" - on-tap="onMenuEditAddressTap_">$i18n{edit}</button> - <button id="menuRemoveAddress" class="dropdown-item" - on-tap="onMenuRemoveAddressTap_">$i18n{removeAddress}</button> + <button id="menuEditAddress" slot="item" class="dropdown-item" + on-click="onMenuEditAddressTap_">$i18n{edit}</button> + <button id="menuRemoveAddress" slot="item" class="dropdown-item" + on-click="onMenuRemoveAddressTap_">$i18n{removeAddress}</button> </dialog> <template is="dom-if" if="[[showAddressDialog_]]" restamp> <settings-address-edit-dialog address="[[activeAddress]]" @@ -139,7 +139,7 @@ <h2 class="start">$i18n{creditCards}</h2> <paper-button id="addCreditCard" class="secondary-button header-aligned-button" - on-tap="onAddCreditCardTap_" + on-click="onAddCreditCardTap_" hidden$="[[isDisabled_(prefs.autofill.credit_card_enabled)]]"> $i18n{add} </paper-button> @@ -173,13 +173,13 @@ <template is="dom-if" if="[[showDots_(item.metadata)]]"> <button is="paper-icon-button-light" id="creditCardMenu" class="icon-more-vert" title="$i18n{moreActions}" - on-tap="onCreditCardMenuTap_"> + on-click="onCreditCardMenuTap_"> </button> </template> <template is="dom-if" if="[[!showDots_(item.metadata)]]"> <button is="paper-icon-button-light" id="remoteCreditCardLink" class="icon-external" - on-tap="onRemoteEditCreditCardTap_" actionable></button> + on-click="onRemoteEditCreditCardTap_" actionable></button> </template> </div> </div> @@ -198,13 +198,13 @@ </div> </div> <dialog is="cr-action-menu" id="creditCardSharedMenu"> - <button id="menuEditCreditCard" class="dropdown-item" - on-tap="onMenuEditCreditCardTap_">$i18n{edit}</button> - <button id="menuRemoveCreditCard" class="dropdown-item" + <button id="menuEditCreditCard" slot="item" class="dropdown-item" + on-click="onMenuEditCreditCardTap_">$i18n{edit}</button> + <button id="menuRemoveCreditCard" slot="item" class="dropdown-item" hidden$="[[!activeCreditCard.metadata.isLocal]]" - on-tap="onMenuRemoveCreditCardTap_">$i18n{removeCreditCard}</button> - <button id="menuClearCreditCard" class="dropdown-item" - on-tap="onMenuClearCreditCardTap_" + on-click="onMenuRemoveCreditCardTap_">$i18n{removeCreditCard}</button> + <button id="menuClearCreditCard" slot="item" class="dropdown-item" + on-click="onMenuClearCreditCardTap_" hidden$="[[!activeCreditCard.metadata.isCached]]"> $i18n{clearCreditCard} </button> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/credit_card_edit_dialog.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/credit_card_edit_dialog.html index 041413545b9..6465c600227 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/credit_card_edit_dialog.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/credit_card_edit_dialog.html @@ -89,9 +89,9 @@ </div> <div slot="button-container"> <paper-button id="cancelButton" class="cancel-button" - on-tap="onCancelButtonTap_">$i18n{cancel}</paper-button> + on-click="onCancelButtonTap_">$i18n{cancel}</paper-button> <paper-button id="saveButton" class="action-button" - on-tap="onSaveButtonTap_" disabled>$i18n{save}</paper-button> + on-click="onSaveButtonTap_" disabled>$i18n{save}</paper-button> </div> </dialog> </template> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_edit_dialog.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_edit_dialog.html index 0a0814697a6..ae402835da2 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_edit_dialog.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_edit_dialog.html @@ -22,7 +22,7 @@ }; --paper-input-container-label-focus: { - color: var(--paper-input-container-color, --secondary-text-color); + color: var(--secondary-text-color); }; } @@ -61,15 +61,15 @@ <button is="paper-icon-button-light" id="showPasswordButton" class$="[[getIconClass_(item.password)]]" hidden$="[[item.entry.federationText]]" - on-tap="onShowPasswordButtonTap_" + on-click="onShowPasswordButtonTap_" title="[[showPasswordTitle_(item.password, '$i18nPolymer{hidePassword}','$i18nPolymer{showPassword}')]]"> </button> </div> </div> <div slot="button-container"> - <paper-button class="action-button" on-tap="onActionButtonTap_"> - $i18n{passwordsDone} + <paper-button class="action-button" on-click="onActionButtonTap_"> + $i18n{done} </paper-button> </div> </dialog> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_list_item.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_list_item.html index 85dda982896..59f3d8a1e3c 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_list_item.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/password_list_item.html @@ -48,12 +48,12 @@ <template is="dom-if" if="[[!item.entry.federationText]]"> <input id="password" aria-label=$i18n{editPasswordPasswordLabel} type="[[getPasswordInputType_(item.password)]]" - on-tap="onReadonlyInputTap_" class="password-field" readonly + on-click="onReadonlyInputTap_" class="password-field" readonly disabled$="[[!item.password]]" value="[[getPassword_(item.password)]]"> <button is="paper-icon-button-light" id="showPasswordButton" class$="[[getIconClass_(item.password)]]" - on-tap="onShowPasswordButtonTap_" + on-click="onShowPasswordButtonTap_" title="[[showPasswordTitle_(item.password, '$i18nPolymer{hidePassword}','$i18nPolymer{showPassword}')]]" focus-row-control focus-type="showPassword"> @@ -66,7 +66,7 @@ </template> </div> <button is="paper-icon-button-light" id="passwordMenu" - class="icon-more-vert" on-tap="onPasswordMenuTap_" + class="icon-more-vert" on-click="onPasswordMenuTap_" title="$i18n{moreActions}" focus-row-control focus-type="passwordMenu"> </button> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_and_forms_page.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_and_forms_page.html index 55dd3fd8ed6..a994bde577f 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_and_forms_page.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_and_forms_page.html @@ -25,10 +25,10 @@ <neon-animatable route-path="default"> <button is="cr-link-row" icon-class="subpage-arrow" id="autofillManagerButton" label="$i18n{autofill}" - sub-label="$i18n{autofillDetail}" on-tap="onAutofillTap_"> + sub-label="$i18n{autofillDetail}" on-click="onAutofillTap_"> </button> <div class="settings-box two-line"> - <div class="start two-line" on-tap="onPasswordsTap_" actionable + <div class="start two-line" on-click="onPasswordsTap_" actionable id="passwordManagerButton"> <div class="flex"> $i18n{passwords} diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html index 9353ae7e5fd..2cfe136b936 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html @@ -8,8 +8,15 @@ <dom-module id="passwords-export-dialog"> <template> <style include="settings-shared iron-flex"> + paper-progress { + width: 100%; + --paper-progress-active-color: var(--google-blue-500); + } + .action-button { + -webkit-margin-start: 8px; + } </style> - <dialog is="cr-dialog" id="dialog" close-text="$i18n{close}"> + <dialog is="cr-dialog" id="dialog_start" close-text="$i18n{close}"> <div slot="title">$i18n{exportPasswordsTitle}</div> <div slot="body"> <div class="layout horizontal center"> @@ -18,15 +25,51 @@ </div> <div slot="button-container"> <paper-button class="secondary-button header-aligned-button" - on-tap="onCancelButtonTap_"> + on-click="onCancelButtonTap_" id="cancelButton"> $i18n{cancel} </paper-button> <paper-button class="action-button header-aligned-button" - on-tap="onExportTap_" id="exportPasswordsButton"> + on-click="onExportTap_" id="exportPasswordsButton"> $i18n{exportPasswords} </paper-button> </div> </dialog> + + <dialog is="cr-dialog" id="dialog_progress" no-cancel="true"> + <div slot="title">$i18n{exportingPasswordsTitle}</div> + <div slot="body"> + <paper-progress indeterminate class="blue"></paper-progress> + </div> + <div slot="button-container"> + <paper-button id="cancel_progress_button" + class="secondary-button header-aligned-button" + on-click="onCancelProgressButtonTap_"> + $i18n{cancel} + </paper-button> + </div> + </dialog> + + <dialog is="cr-dialog" id="dialog_error" close-text="$i18n{close}"> + <div slot="title">[[exportErrorMessage]]</div> + <div slot="body"> + $i18n{exportPasswordsFailTips} + <ul> + <li>$i18n{exportPasswordsFailTipsEnoughSpace}</li> + <li>$i18n{exportPasswordsFailTipsAnotherFolder}</li> + </ul> + </div> + <div slot="button-container"> + <paper-button class="secondary-button header-aligned-button" + on-click="onCancelButtonTap_" id="cancelErrorButton"> + $i18n{cancel} + </paper-button> + <paper-button class="action-button header-aligned-button" + on-click="onExportTap_" id="tryAgainButton"> + $i18n{exportPasswordsTryAgain} + </paper-button> + </div> + </dialog> + </template> <script src="passwords_export_dialog.js"></script> -</dom-module>
\ No newline at end of file +</dom-module> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js index da06563ff1c..737b3f504e4 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js @@ -10,9 +10,43 @@ (function() { 'use strict'; +/** + * The states of the export passwords dialog. + * @enum {string} + */ +const States = { + START: 'START', + IN_PROGRESS: 'IN_PROGRESS', + ERROR: 'ERROR', +}; + +const ProgressStatus = chrome.passwordsPrivate.ExportProgressStatus; + +/** + * The amount of time (ms) between the start of the export and the moment we + * start showing the progress bar. + * @type {number} + */ +const progressBarDelayMs = 100; + +/** + * The minimum amount of time (ms) that the progress bar will be visible. + * @type {number} + */ +const progressBarBlockMs = 1000; + Polymer({ is: 'passwords-export-dialog', + behaviors: [I18nBehavior], + + properties: { + /** The error that occurred while exporting. */ + exportErrorMessage: String, + }, + + listeners: {'cancel': 'close'}, + /** * The interface for callbacks to the browser. * Defined in passwords_section.js @@ -21,16 +55,114 @@ Polymer({ */ passwordManager_: null, + /** @private {function(!PasswordManager.PasswordExportProgress):void} */ + onPasswordsFileExportProgressListener_: null, + + /** + * The task that will display the progress bar, if the export doesn't finish + * quickly. This is null, unless the task is currently scheduled. + * @private {?number} + */ + progressTaskToken_: null, + + /** + * The task that will display the completion of the export, if any. We display + * the progress bar for at least |progressBarBlockMs|, therefore, if export + * finishes earlier, we cache the result in |delayedProgress_| and this task + * will consume it. This is null, unless the task is currently scheduled. + * @private {?number} + */ + delayedCompletionToken_: null, + + /** + * We display the progress bar for at least |progressBarBlockMs|. If progress + * is achieved earlier, we store the update here and consume it later. + * @private {?PasswordManager.PasswordExportProgress} + */ + delayedProgress_: null, + /** @override */ attached: function() { - this.$.dialog.showModal(); - this.passwordManager_ = PasswordManagerImpl.getInstance(); + + this.switchToDialog_(States.START); + + this.onPasswordsFileExportProgressListener_ = + this.onPasswordsFileExportProgress_.bind(this); + + // If export started on a different tab and is still in progress, display a + // busy UI. + this.passwordManager_.requestExportProgressStatus(status => { + if (status == ProgressStatus.IN_PROGRESS) + this.switchToDialog_(States.IN_PROGRESS); + }); + + this.passwordManager_.addPasswordsFileExportProgressListener( + this.onPasswordsFileExportProgressListener_); + }, + + /** + * Handles an export progress event by changing the visible dialog or caching + * the event for later consumption. + * @param {!PasswordManager.PasswordExportProgress} progress + * @private + */ + onPasswordsFileExportProgress_(progress) { + // If Chrome has already started displaying the progress bar + // (|progressTaskToken_ is null) and hasn't completed its minimum display + // time (|delayedCompletionToken_| is not null) progress should be cached + // for consumption when the blocking time ends. + const progressBlocked = + !this.progressTaskToken_ && !!this.delayedCompletionToken_; + if (!progressBlocked) { + clearTimeout(this.progressTaskToken_); + this.progressTaskToken_ = null; + this.processProgress_(progress); + } else { + this.delayedProgress_ = progress; + } + }, + + /** + * Displays the progress bar and suspends further UI updates for + * |progressBarBlockMs|. + * @private + */ + progressTask_() { + this.progressTaskToken_ = null; + this.switchToDialog_(States.IN_PROGRESS); + + this.delayedCompletionToken_ = + setTimeout(this.delayedCompletionTask_.bind(this), progressBarBlockMs); + }, + + /** + * Unblocks progress after showing the progress bar for |progressBarBlock|ms + * and processes any progress that was delayed. + * @private + */ + delayedCompletionTask_() { + this.delayedCompletionToken_ = null; + if (this.delayedProgress_) { + this.processProgress_(this.delayedProgress_); + this.delayedProgress_ = null; + } }, /** Closes the dialog. */ close: function() { - this.$.dialog.close(); + clearTimeout(this.progressTaskToken_); + clearTimeout(this.delayedCompletionToken_); + this.progressTaskToken_ = null; + this.delayedCompletionToken_ = null; + this.passwordManager_.removePasswordsFileExportProgressListener( + this.onPasswordsFileExportProgressListener_); + if (this.$.dialog_start.open) + this.$.dialog_start.close(); + if (this.$.dialog_progress.open) + this.$.dialog_progress.close(); + if (this.$.dialog_error.open) + this.$.dialog_error.close(); }, /** @@ -38,7 +170,56 @@ Polymer({ * @private */ onExportTap_: function() { - this.passwordManager_.exportPasswords(); + this.passwordManager_.exportPasswords(() => { + if (chrome.runtime.lastError && + chrome.runtime.lastError.message == 'in-progress') { + // Exporting was started by a different call to exportPasswords() and is + // is still in progress. This UI needs to be updated to the current + // status. + this.switchToDialog_(States.IN_PROGRESS); + } + }); + }, + + /** + * Prepares and displays the appropriate view (with delay, if necessary). + * @param {!PasswordManager.PasswordExportProgress} progress + * @private + */ + processProgress_(progress) { + if (progress.status == ProgressStatus.IN_PROGRESS) { + this.progressTaskToken_ = + setTimeout(this.progressTask_.bind(this), progressBarDelayMs); + return; + } + if (progress.status == ProgressStatus.SUCCEEDED) { + this.close(); + return; + } + if (progress.status == ProgressStatus.FAILED_WRITE_FAILED) { + this.exportErrorMessage = + this.i18n('exportPasswordsFailTitle', progress.folderName); + this.switchToDialog_(States.ERROR); + return; + } + }, + + /** + * Opens the specified dialog and hides the others. + * @param {!States} state the dialog to open. + * @private + */ + switchToDialog_(state) { + this.$.dialog_start.open = false; + this.$.dialog_error.open = false; + this.$.dialog_progress.open = false; + + if (state == States.START) + this.$.dialog_start.showModal(); + if (state == States.ERROR) + this.$.dialog_error.showModal(); + if (state == States.IN_PROGRESS) + this.$.dialog_progress.showModal(); }, /** @@ -48,5 +229,15 @@ Polymer({ onCancelButtonTap_: function() { this.close(); }, + + /** + * Handler for tapping the 'cancel' button on the progress dialog. It should + * cancel the export and dismiss the dialog. + * @private + */ + onCancelProgressButtonTap_: function() { + this.passwordManager_.cancelExportPasswords(); + this.close(); + }, }); })();
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.html b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.html index 4b47e458f25..956780bb648 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.html +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.html @@ -52,12 +52,16 @@ #undoToast { z-index: 1; - } + } + + #exportImportMenuButton { + -webkit-margin-end: 0; + } </style> <settings-toggle-button id="passwordToggle" - class="first primary-toggle" + class="first" aria-label="$i18n{passwords}" no-extension-indicator - label="[[getOnOffLabel_(prefs.credentials_enable_service.value)]]" + label="$i18n{passwordsSavePasswordsLabel}" pref="{{prefs.credentials_enable_service}}"> </settings-toggle-button> <template is="dom-if" @@ -89,7 +93,7 @@ if="[[showImportOrExportPasswords_( showExportPasswords_, showImportPasswords_)]]"> <button is="paper-icon-button-light" id="exportImportMenuButton" - class="icon-more-vert" on-tap="onImportExportMenuTap_" + class="icon-more-vert" on-click="onImportExportMenuTap_" title="$i18n{moreActions}" focus-type="exportImportMenuButton"> </button> </template> @@ -122,20 +126,20 @@ </div> </div> <dialog is="cr-action-menu" id="menu"> - <button id="menuEditPassword" class="dropdown-item" - on-tap="onMenuEditPasswordTap_">$i18n{passwordViewDetails}</button> - <button id="menuRemovePassword" class="dropdown-item" - on-tap="onMenuRemovePasswordTap_">$i18n{removePassword}</button> + <button id="menuEditPassword" slot="item" class="dropdown-item" + on-click="onMenuEditPasswordTap_">$i18n{passwordViewDetails}</button> + <button id="menuRemovePassword" slot="item" class="dropdown-item" + on-click="onMenuRemovePasswordTap_">$i18n{removePassword}</button> </dialog> <dialog is="cr-action-menu" id="exportImportMenu"> - <template is="dom-if" if="[[showImportPasswords_]]"> - <button id="menuImportPassword" class="dropdown-item" - on-tap="onImportTap_">$i18n{import}</button> - </template> - <template is="dom-if" if="[[showExportPasswords_]]"> - <button id="menuExportPassword" class="dropdown-item" - on-tap="onExportTap_">$i18n{export}</button> - </template> + <button id="menuImportPassword" slot="item" class="dropdown-item" + on-click="onImportTap_" hidden="[[!showImportPasswords_]]"> + $i18n{import} + </button> + <button id="menuExportPassword" slot="item" class="dropdown-item" + on-click="onExportTap_" hidden="[[!showExportPasswords_]]"> + $i18n{exportMenuItem} + </button> </dialog> <template is="dom-if" if="[[showPasswordsExportDialog_]]" restamp> <passwords-export-dialog on-close="onPasswordsExportDialogClosed_"> @@ -148,7 +152,7 @@ </template> <cr-toast id="undoToast" duration="[[toastDuration_]]"> <div id="undoLabel">$i18n{passwordDeleted}</div> - <paper-button id="undoButton" on-tap="onUndoButtonTap_"> + <paper-button id="undoButton" on-click="onUndoButtonTap_"> $i18n{undoRemovePassword} </paper-button> </cr-toast> @@ -165,7 +169,7 @@ </a> </div> <button is="paper-icon-button-light" id="removeExceptionButton" - class="icon-clear" on-tap="onRemoveExceptionButtonTap_" + class="icon-clear" on-click="onRemoveExceptionButtonTap_" tabindex$="[[tabIndex]]" title="$i18n{deletePasswordException}"> </button> diff --git a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.js b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.js index d4f0f70ac06..33cb93e78a8 100644 --- a/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.js +++ b/chromium/chrome/browser/resources/settings/passwords_and_forms_page/passwords_section.js @@ -10,6 +10,8 @@ /** * Interface for all callbacks to the password API. + * TODO(crbug.com/802352) Move the PasswordManager proxy to a separate + * location. * @interface */ class PasswordManager { @@ -84,8 +86,32 @@ class PasswordManager { /** * Triggers the dialogue for exporting passwords. + * @param {function():void} callback */ - exportPasswords() {} + exportPasswords(callback) {} + + /** + * Cancels the ongoing export of passwords. + */ + cancelExportPasswords(callback) {} + + /** + * Queries the status of any ongoing export. + * @param {function(!PasswordManager.ExportProgressStatus):void} callback + */ + requestExportProgressStatus(callback) {} + + /** + * Add an observer to the export progress. + * @param {function(!PasswordManager.PasswordExportProgress):void} listener + */ + addPasswordsFileExportProgressListener(listener) {} + + /** + * Remove an observer from the export progress. + * @param {function(!PasswordManager.PasswordExportProgress):void} listener + */ + removePasswordsFileExportProgressListener(listener) {} } /** @typedef {chrome.passwordsPrivate.PasswordUiEntry} */ @@ -103,6 +129,12 @@ PasswordManager.PlaintextPasswordEvent; /** @typedef {{ entry: !PasswordManager.PasswordUiEntry, password: string }} */ PasswordManager.UiEntryWithPassword; +/** @typedef {chrome.passwordsPrivate.PasswordExportProgress} */ +PasswordManager.PasswordExportProgress; + +/** @typedef {chrome.passwordsPrivate.ExportProgressStatus} */ +PasswordManager.ExportProgressStatus; + /** * Implementation that accesses the private API. * @implements {PasswordManager} @@ -176,8 +208,29 @@ class PasswordManagerImpl { } /** @override */ - exportPasswords() { - chrome.passwordsPrivate.exportPasswords(); + exportPasswords(callback) { + chrome.passwordsPrivate.exportPasswords(callback); + } + + /** @override */ + cancelExportPasswords() { + chrome.passwordsPrivate.cancelExportPasswords(); + } + + /** @override */ + requestExportProgressStatus(callback) { + chrome.passwordsPrivate.requestExportProgressStatus(callback); + } + + /** @override */ + addPasswordsFileExportProgressListener(listener) { + chrome.passwordsPrivate.onPasswordsFileExportProgress.addListener(listener); + } + + /** @override */ + removePasswordsFileExportProgressListener(listener) { + chrome.passwordsPrivate.onPasswordsFileExportProgress.removeListener( + listener); } } @@ -251,10 +304,7 @@ Polymer({ /** @private */ showExportPasswords_: { type: Boolean, - value: function() { - return loadTimeData.valueExists('showExportPasswords') && - loadTimeData.getBoolean('showExportPasswords'); - } + computed: 'showExportPasswordsAndReady_(savedPasswords)' }, /** @private */ @@ -489,6 +539,7 @@ Polymer({ */ onImportTap_: function() { this.passwordManager_.importPasswords(); + this.$.exportImportMenu.close(); }, /** @@ -503,6 +554,8 @@ Polymer({ /** @private */ onPasswordsExportDialogClosed_: function() { this.showPasswordsExportDialog_ = false; + cr.ui.focusWithoutInk(assert(this.activeDialogAnchor_)); + this.activeDialogAnchor_ = null; }, /** @@ -538,6 +591,16 @@ Polymer({ /** * @private + * @param {!Array<!PasswordManager.PasswordUiEntry>} savedPasswords + */ + showExportPasswordsAndReady_: function(savedPasswords) { + return loadTimeData.valueExists('showExportPasswords') && + loadTimeData.getBoolean('showExportPasswords') && + savedPasswords.length > 0; + }, + + /** + * @private * @param {boolean} showExportPasswords * @param {boolean} showImportPasswords * @return {boolean} diff --git a/chromium/chrome/browser/resources/settings/people_page/change_picture.html b/chromium/chrome/browser/resources/settings/people_page/change_picture.html index 87ec0b1917d..f14495a8af7 100644 --- a/chromium/chrome/browser/resources/settings/people_page/change_picture.html +++ b/chromium/chrome/browser/resources/settings/people_page/change_picture.html @@ -106,7 +106,7 @@ choose-file-label="$i18n{chooseFile}" old-image-label="$i18n{oldPhoto}" profile-image-label="$i18n{profilePhoto}" - take-photo-label="$i18n{takePhoto}"> + take-photo-label="$i18n{takePhoto}" capture-video-label="$i18n{captureVideo}"> </cr-picture-list> </div> diff --git a/chromium/chrome/browser/resources/settings/people_page/change_picture.js b/chromium/chrome/browser/resources/settings/people_page/change_picture.js index 48eaa44ed61..eeca32878b8 100644 --- a/chromium/chrome/browser/resources/settings/people_page/change_picture.js +++ b/chromium/chrome/browser/resources/settings/people_page/change_picture.js @@ -81,6 +81,9 @@ Polymer({ /** @private {?CrPictureListElement} */ pictureList_: null, + /** @private {boolean} */ + oldImagePending_: false, + /** @override */ ready: function() { this.browserProxy_ = settings.ChangePictureBrowserProxyImpl.getInstance(); @@ -144,6 +147,7 @@ Polymer({ * @private */ receiveOldImage_: function(imageInfo) { + this.oldImagePending_ = false; this.pictureList_.setOldImageUrl(imageInfo.url, imageInfo.index); }, @@ -216,6 +220,7 @@ Polymer({ * @private */ onPhotoTaken_: function(event) { + this.oldImagePending_ = true; this.browserProxy_.photoTaken(event.detail.photoDataUrl); this.pictureList_.setOldImageUrl(event.detail.photoDataUrl); this.pictureList_.setFocus(); @@ -235,6 +240,9 @@ Polymer({ /** @private */ onDiscardImage_: function() { + // Prevent image from being discarded if old image is pending. + if (this.oldImagePending_) + return; this.pictureList_.setOldImageUrl(CrPicture.kDefaultImageUrl); // Revert to profile image as we don't know what last used default image is. this.browserProxy_.selectProfileImage(); diff --git a/chromium/chrome/browser/resources/settings/people_page/change_picture_browser_proxy.js b/chromium/chrome/browser/resources/settings/people_page/change_picture_browser_proxy.js index 7295e66f6a0..3cd333e51bd 100644 --- a/chromium/chrome/browser/resources/settings/people_page/change_picture_browser_proxy.js +++ b/chromium/chrome/browser/resources/settings/people_page/change_picture_browser_proxy.js @@ -51,8 +51,9 @@ cr.define('settings', function() { selectProfileImage() {} /** - * Provides the taken photo as a data URL to the C++. No response is - * expected. + * Provides the taken photo as a data URL to the C++ and sets the user + * image to the 'old' image. As a response, the C++ sends the + * 'old-image-changed' WebUIListener event. * @param {string} photoDataUrl */ photoTaken(photoDataUrl) {} diff --git a/chromium/chrome/browser/resources/settings/people_page/compiled_resources2.gyp b/chromium/chrome/browser/resources/settings/people_page/compiled_resources2.gyp index 3159cd12ca1..5c15adeb340 100644 --- a/chromium/chrome/browser/resources/settings/people_page/compiled_resources2.gyp +++ b/chromium/chrome/browser/resources/settings/people_page/compiled_resources2.gyp @@ -255,5 +255,19 @@ ], 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], }, + { + 'target_name': 'sync_account_control', + 'dependencies': [ + '../compiled_resources2.gyp:route', + '../prefs/compiled_resources2.gyp:prefs_behavior', + '<(DEPTH)/ui/webui/resources/cr_elements/cr_action_menu/compiled_resources2.gyp:cr_action_menu', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:load_time_data', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:icon', + '<(DEPTH)/ui/webui/resources/js/compiled_resources2.gyp:web_ui_listener_behavior', + 'profile_info_browser_proxy', + 'sync_browser_proxy', + ], + 'includes': ['../../../../../third_party/closure_compiler/compile_js2.gypi'], + }, ], } diff --git a/chromium/chrome/browser/resources/settings/people_page/easy_unlock_turn_off_dialog.html b/chromium/chrome/browser/resources/settings/people_page/easy_unlock_turn_off_dialog.html index 211d825ee32..dd65510f60f 100644 --- a/chromium/chrome/browser/resources/settings/people_page/easy_unlock_turn_off_dialog.html +++ b/chromium/chrome/browser/resources/settings/people_page/easy_unlock_turn_off_dialog.html @@ -21,11 +21,12 @@ hidden="[[isButtonBarHidden_(status_)]]"> <paper-spinner-lite active="[[isSpinnerActive_(status_)]]"> </paper-spinner-lite> - <paper-button class="cancel-button" on-tap="onCancelTap_" + <paper-button class="cancel-button" on-click="onCancelTap_" hidden="[[isCancelButtonHidden_(status_)]]"> $i18n{cancel} </paper-button> - <paper-button id="turnOff" class="action-button" on-tap="onTurnOffTap_" + <paper-button id="turnOff" class="action-button" + on-click="onTurnOffTap_" disabled="[[!isTurnOffButtonEnabled_(status_)]]"> [[getTurnOffButtonText_(status_)]] </paper-button> diff --git a/chromium/chrome/browser/resources/settings/people_page/fingerprint_list.html b/chromium/chrome/browser/resources/settings/people_page/fingerprint_list.html index 6c62e3e44af..c98c91b9c8a 100644 --- a/chromium/chrome/browser/resources/settings/people_page/fingerprint_list.html +++ b/chromium/chrome/browser/resources/settings/people_page/fingerprint_list.html @@ -29,7 +29,7 @@ } .body { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; } .list-item { @@ -55,14 +55,14 @@ on-change="onFingerprintLabelChanged_"> </paper-input> <button is="paper-icon-button-light" class="icon-delete-gray" - on-tap="onFingerprintDeleteTapped_"> + on-click="onFingerprintDeleteTapped_"> </button> </div> </template> </iron-list> <div class="continuation"> <paper-button id="addFingerprint" class="add-link action-button" - on-tap="openAddFingerprintDialog_"> + on-click="openAddFingerprintDialog_"> $i18n{lockScreenAddFingerprint} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/people_page/import_data_dialog.html b/chromium/chrome/browser/resources/settings/people_page/import_data_dialog.html index 985a4aec02c..cb07d334450 100644 --- a/chromium/chrome/browser/resources/settings/people_page/import_data_dialog.html +++ b/chromium/chrome/browser/resources/settings/people_page/import_data_dialog.html @@ -104,7 +104,7 @@ importStatusEnum_.SUCCEEDED, importStatus_)]]" disabled="[[hasImportStatus_( importStatusEnum_.IN_PROGRESS, importStatus_)]]" - on-tap="closeDialog_"> + on-click="closeDialog_"> $i18n{cancel} </paper-button> <paper-button id="import" class="action-button" @@ -112,14 +112,14 @@ importStatusEnum_.SUCCEEDED, importStatus_)]]" disabled="[[shouldDisableImport_( importStatus_, noImportDataTypeSelected_)]]" - on-tap="onActionButtonTap_"> + on-click="onActionButtonTap_"> [[getActionButtonText_(selected_)]] </paper-button> <paper-button id="done" class="action-button" hidden$="[[!hasImportStatus_( importStatusEnum_.SUCCEEDED, importStatus_)]]" - on-tap="closeDialog_">$i18n{done}</paper-button> + on-click="closeDialog_">$i18n{done}</paper-button> </div> </dialog> </template> diff --git a/chromium/chrome/browser/resources/settings/people_page/lock_screen.html b/chromium/chrome/browser/resources/settings/people_page/lock_screen.html index cdaa2505cae..d12222f1f36 100644 --- a/chromium/chrome/browser/resources/settings/people_page/lock_screen.html +++ b/chromium/chrome/browser/resources/settings/people_page/lock_screen.html @@ -59,7 +59,7 @@ } #easyUnlockSettingsCollapsible { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; } .no-padding { @@ -111,7 +111,7 @@ <div id="pinPasswordSecondaryActionDiv" class="secondary-action"> <paper-button id="setupPinButton" class="secondary-button" - on-tap="onConfigurePin_"> + on-click="onConfigurePin_"> [[getSetupPinText_(hasPin)]] </paper-button> </div> @@ -140,7 +140,7 @@ <div class="separator"></div> <div class="secondary-action"> <paper-button class="secondary-button" - on-tap="onEditFingerprints_" + on-click="onEditFingerprints_" aria-label="$i18n{lockScreenEditFingerprints}" aria-descibedby="lockScreenEditFingerprintsSecondary"> $i18n{lockScreenSetupFingerprintButton} @@ -167,13 +167,13 @@ <div class="separator"></div> <template is="dom-if" if="[[!easyUnlockEnabled_]]"> <paper-button id="easyUnlockSetup" class="secondary-button" - on-tap="onEasyUnlockSetupTap_"> + on-click="onEasyUnlockSetupTap_"> $i18n{easyUnlockSetupButton} </paper-button> </template> <template is="dom-if" if="[[easyUnlockEnabled_]]"> <paper-button id="easyUnlockTurnOff" class="secondary-button" - on-tap="onEasyUnlockTurnOffTap_"> + on-click="onEasyUnlockTurnOffTap_"> $i18n{easyUnlockTurnOffButton} </paper-button> </template> diff --git a/chromium/chrome/browser/resources/settings/people_page/password_prompt_dialog.html b/chromium/chrome/browser/resources/settings/people_page/password_prompt_dialog.html index 9a7dac7a58c..68056695bfe 100644 --- a/chromium/chrome/browser/resources/settings/people_page/password_prompt_dialog.html +++ b/chromium/chrome/browser/resources/settings/people_page/password_prompt_dialog.html @@ -34,11 +34,11 @@ </paper-input> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_"> + <paper-button class="cancel-button" on-click="onCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="submitPassword_" + <paper-button class="action-button" on-click="submitPassword_" disabled$="[[!enableConfirm_(password_, passwordInvalid_)]]"> $i18n{confirm} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/people_page/people_page.html b/chromium/chrome/browser/resources/settings/people_page/people_page.html index 4ab0dfcd81f..d7b6822e5ec 100644 --- a/chromium/chrome/browser/resources/settings/people_page/people_page.html +++ b/chromium/chrome/browser/resources/settings/people_page/people_page.html @@ -33,6 +33,7 @@ <link rel="import" href="users_page.html"> </if> <if expr="not chromeos"> +<link rel="import" href="sync_account_control.html"> <link rel="import" href="import_data_dialog.html"> <link rel="import" href="manage_profile.html"> </if> @@ -46,9 +47,7 @@ } #profile-icon { - background-position: center; - background-repeat: no-repeat; - background-size: cover; + background: center / cover no-repeat; border-radius: 20px; flex-shrink: 0; height: 40px; @@ -102,66 +101,80 @@ <settings-animated-pages id="pages" section="people" focus-config="[[focusConfig_]]"> <neon-animatable route-path="default"> - <div id="picture-subpage-trigger" class="settings-box first two-line"> - <template is="dom-if" if="[[syncStatus]]"> - <div id="profile-icon" on-tap="onPictureTap_" actionable - style="background-image: [[getIconImageSet_(profileIconUrl_)]]"> - </div> <if expr="not chromeos"> - <div class="middle two-line no-min-width" - on-tap="onProfileNameTap_" actionable> -</if> -<if expr="chromeos"> - <div class="middle two-line no-min-width" on-tap="onPictureTap_" - actionable> + <template is="dom-if" if="[[shouldShowSyncAccountControl_(diceEnabled_, + syncStatus.syncSystemEnabled, syncStatus.signinAllowed)]]"> + <settings-sync-account-control + promo-label="$i18n{peopleSignInPrompt}" + promo-secondary-label="$i18n{peopleSignInPromptSecondary}"> + </settings-sync-account-control> + </template> + <template is="dom-if" if="[[!diceEnabled_]]"> </if> - <div class="flex text-elide"> - <span id="profile-name">[[profileName_]]</span> - <div class="secondary" hidden="[[!syncStatus.signedIn]]"> - [[syncStatus.signedInUsername]] - </div> + <div id="picture-subpage-trigger" class="settings-box first two-line"> + <template is="dom-if" if="[[syncStatus]]"> + <div id="profile-icon" on-click="onProfileTap_" actionable + style="background-image: [[getIconImageSet_( + profileIconUrl_)]]"> </div> + <div class="middle two-line no-min-width" on-click="onProfileTap_" + actionable> + <div class="flex text-elide"> + <span id="profile-name">[[profileName_]]</span> + <div class="secondary" hidden="[[!syncStatus.signedIn]]"> + [[syncStatus.signedInUsername]] + </div> + </div> <if expr="not chromeos"> - <button class="subpage-arrow" is="paper-icon-button-light" - aria-label="$i18n{editPerson}" - aria-describedby="profile-name"></button> + <button class="subpage-arrow" is="paper-icon-button-light" + aria-label="$i18n{editPerson}" + aria-describedby="profile-name"></button> </if> <if expr="chromeos"> - <button class="subpage-arrow" is="paper-icon-button-light" - aria-label="$i18n{changePictureTitle}" - aria-describedby="profile-name"></button> + <button class="subpage-arrow" is="paper-icon-button-light" + aria-label="$i18n{changePictureTitle}" + aria-describedby="profile-name"></button> </if> - </div> + </div> <if expr="not chromeos"> - <template is="dom-if" if="[[showSignin_(syncStatus)]]"> - <div class="separator"></div> - <paper-button class="primary-button" on-tap="onSigninTap_" - disabled="[[syncStatus.setupInProgress]]"> - $i18n{syncSignin} - </paper-button> - </template> - <template is="dom-if" if="[[syncStatus.signedIn]]"> - <div class="separator"></div> - <paper-button id="disconnectButton" class="secondary-button" - on-tap="onDisconnectTap_" - disabled="[[syncStatus.setupInProgress]]"> - $i18n{syncDisconnect} - </paper-button> + <template is="dom-if" if="[[showSignin_(syncStatus)]]"> + <div class="separator"></div> + <paper-button class="primary-button" on-click="onSigninTap_" + disabled="[[syncStatus.setupInProgress]]"> + $i18n{syncSignin} + </paper-button> + </template> + <template is="dom-if" if="[[syncStatus.signedIn]]"> + <div class="separator"></div> + <paper-button id="disconnectButton" class="secondary-button" + on-click="onDisconnectTap_" + disabled="[[syncStatus.setupInProgress]]"> + $i18n{syncDisconnect} + </paper-button> + </template> +</if> </template> + </div> +<if expr="not chromeos"> + </template> <!-- if="[[!diceEnabled_]]" --> </if> - </template> - </div> <template is="dom-if" if="[[!syncStatus.signedIn]]"> - <div class="settings-box two-line" - hidden="[[!syncStatus.signinAllowed]]"> - <div class="start"> - $i18n{syncOverview} - <a target="_blank" href="$i18n{syncLearnMoreUrl}"> - $i18n{learnMore} - </a> +<if expr="not chromeos"> + <template is="dom-if" if="[[!diceEnabled_]]"> +</if> + <div class="settings-box two-line" id="sync-overview" + hidden="[[!syncStatus.signinAllowed]]"> + <div class="start"> + $i18n{syncOverview} + <a target="_blank" href="$i18n{syncLearnMoreUrl}"> + $i18n{learnMore} + </a> + </div> </div> - </div> +<if expr="not chromeos"> + </template> <!-- if="[[!diceEnabled_]]" --> +</if> <div class="settings-box" hidden="[[syncStatus.signinAllowed]]"> $i18n{syncDisabledByAdministrator} </div> @@ -182,7 +195,7 @@ <template is="dom-if" if="[[isAdvancedSyncSettingsVisible_(syncStatus)]]"> - <div class="settings-box two-line" on-tap="onSyncTap_" + <div class="settings-box two-line" on-click="onSyncTap_" id="sync-status" actionable$="[[isSyncStatusActionable_( syncStatus)]]"> <div class="icon-container"> @@ -202,9 +215,20 @@ </div> </template> +<if expr="not chromeos"> + <template is="dom-if" if="[[diceEnabled_]]"> + <div class="settings-box" id="edit-profile" on-click="onProfileTap_" + actionable> + <div class="start">$i18n{profileNameAndPicture}</div> + <button class="subpage-arrow" is="paper-icon-button-light" + aria-label="$i18n{editPerson}"></button> + </div> + </template> +</if> + <if expr="chromeos"> <div id="lock-screen-subpage-trigger" class="settings-box two-line" - actionable on-tap="onConfigureLockTap_"> + actionable on-click="onConfigureLockTap_"> <div class="start"> $i18n{lockScreenTitle} <div class="secondary" id="lockScreenSecondary"> @@ -219,14 +243,14 @@ </if> <div id="manage-other-people-subpage-trigger" - class="settings-box" on-tap="onManageOtherPeople_" actionable> + class="settings-box" on-click="onManageOtherPeople_" actionable> <div class="start">$i18n{manageOtherPeople}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{manageOtherPeople}"></button> </div> <if expr="not chromeos"> - <div class="settings-box" on-tap="onImportDataTap_" actionable> + <div class="settings-box" on-click="onImportDataTap_" actionable> <div class="start">$i18n{importTitle}</div> <button id="importDataDialogTrigger" class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{importTitle}"> @@ -234,16 +258,6 @@ </div> </if> - <template is="dom-if" if="[[profileManagesSupervisedUsers_]]"> - <a id="manageSupervisedUsersContainer" - class="settings-box inherit-color no-outline" tabindex="-1" - target="_blank" href="$i18n{supervisedUsersUrl}"> - <div class="start">$i18n{manageSupervisedUsers}</div> - <button class="icon-external" is="paper-icon-button-light" - actionable aria-label="$i18n{manageSupervisedUsers}"> - </button> - </a> - </template> </neon-animatable> <template is="dom-if" route-path="/syncSetup" no-search$="[[!isAdvancedSyncSettingsVisible_(syncStatus)]]"> @@ -274,10 +288,7 @@ <settings-subpage associated-control="[[$$('#manage-other-people-subpage-trigger')]]" page-title="$i18n{manageOtherPeople}"> - <settings-users-page - prefs="{{prefs}}" - profile-manages-supervised-users= - "[[profileManagesSupervisedUsers_]]"> + <settings-users-page prefs="{{prefs}}"> </settings-users-page> </settings-subpage> </template> @@ -313,16 +324,16 @@ </div> </div> <div slot="button-container"> - <paper-button on-tap="onDisconnectCancel_" class="cancel-button"> + <paper-button on-click="onDisconnectCancel_" class="cancel-button"> $i18n{cancel} </paper-button> <paper-button id="disconnectConfirm" class="action-button" - hidden="[[syncStatus.domain]]" on-tap="onDisconnectConfirm_"> + hidden="[[syncStatus.domain]]" on-click="onDisconnectConfirm_"> $i18n{syncDisconnect} </paper-button> <paper-button id="disconnectManagedProfileConfirm" class="action-button" hidden="[[!syncStatus.domain]]" - on-tap="onDisconnectConfirm_"> + on-click="onDisconnectConfirm_"> $i18n{syncDisconnectConfirm} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/people_page/people_page.js b/chromium/chrome/browser/resources/settings/people_page/people_page.js index 60555a4e8a4..d1be1d2c850 100644 --- a/chromium/chrome/browser/resources/settings/people_page/people_page.js +++ b/chromium/chrome/browser/resources/settings/people_page/people_page.js @@ -25,6 +25,22 @@ Polymer({ notify: true, }, + // <if expr="not chromeos"> + /** + * This flag is used to conditionally show a set of new sign-in UIs to the + * profiles that have been migrated to be consistent with the web sign-ins. + * TODO(scottchen): In the future when all profiles are completely migrated, + * this should be removed, and UIs hidden behind it should become default. + * @private + */ + diceEnabled_: { + type: Boolean, + value: function() { + return loadTimeData.getBoolean('diceEnabled'); + }, + }, + // </if> + /** * The current sync status, supplied by SyncBrowserProxy. * @type {?settings.SyncStatus} @@ -33,33 +49,33 @@ Polymer({ /** * The currently selected profile icon URL. May be a data URL. + * @private */ profileIconUrl_: String, /** * The current profile name. + * @private */ profileName_: String, /** - * True if the current profile manages supervised users. - */ - profileManagesSupervisedUsers_: Boolean, - - /** * The profile deletion warning. The message indicates the number of * profile stats that will be deleted if a non-zero count for the profile * stats is returned from the browser. + * @private */ deleteProfileWarning_: String, /** * True if the profile deletion warning is visible. + * @private */ deleteProfileWarningVisible_: Boolean, /** * True if the checkbox to delete the profile has been checked. + * @private */ deleteProfile_: Boolean, @@ -134,12 +150,6 @@ Polymer({ this.addWebUIListener( 'profile-info-changed', this.handleProfileInfo_.bind(this)); - profileInfoProxy.getProfileManagesSupervisedUsers().then( - this.handleProfileManagesSupervisedUsers_.bind(this)); - this.addWebUIListener( - 'profile-manages-supervised-users-changed', - this.handleProfileManagesSupervisedUsers_.bind(this)); - this.addWebUIListener( 'profile-stats-count-ready', this.handleProfileStatsCount_.bind(this)); @@ -209,15 +219,6 @@ Polymer({ }, /** - * Handler for when the profile starts or stops managing supervised users. - * @private - * @param {boolean} managesSupervisedUsers - */ - handleProfileManagesSupervisedUsers_: function(managesSupervisedUsers) { - this.profileManagesSupervisedUsers_ = managesSupervisedUsers; - }, - - /** * Handler for when the profile stats count is pushed from the browser. * @param {number} count * @private @@ -251,7 +252,7 @@ Polymer({ }, /** @private */ - onPictureTap_: function() { + onProfileTap_: function() { // <if expr="chromeos"> settings.navigateTo(settings.routes.CHANGE_PICTURE); // </if> @@ -260,13 +261,6 @@ Polymer({ // </if> }, - // <if expr="not chromeos"> - /** @private */ - onProfileNameTap_: function() { - settings.navigateTo(settings.routes.MANAGE_PROFILE); - }, - // </if> - /** @private */ onSigninTap_: function() { this.syncBrowserProxy_.startSignIn(); @@ -275,7 +269,16 @@ Polymer({ /** @private */ onDisconnectClosed_: function() { this.showDisconnectDialog_ = false; + // <if expr="not chromeos"> + if (!this.diceEnabled_) { + // If DICE-enabled, this button won't exist here. + cr.ui.focusWithoutInk(assert(this.$$('#disconnectButton'))); + } + // </if> + + // <if expr="chromeos"> cr.ui.focusWithoutInk(assert(this.$$('#disconnectButton'))); + // </if> if (settings.getCurrentRoute() == settings.routes.SIGN_OUT) settings.navigateToPreviousRoute(); @@ -391,6 +394,15 @@ Polymer({ settings.navigateToPreviousRoute(); cr.ui.focusWithoutInk(assert(this.$.importDataDialogTrigger)); }, + + /** + * @return {boolean} + * @private + */ + shouldShowSyncAccountControl_: function() { + return !!this.diceEnabled_ && !!this.syncStatus.syncSystemEnabled && + !!this.syncStatus.signinAllowed; + }, // </if> /** diff --git a/chromium/chrome/browser/resources/settings/people_page/pin_keyboard.html b/chromium/chrome/browser/resources/settings/people_page/pin_keyboard.html deleted file mode 100644 index 65056df0516..00000000000 --- a/chromium/chrome/browser/resources/settings/people_page/pin_keyboard.html +++ /dev/null @@ -1 +0,0 @@ -<include src="../../chromeos/quick_unlock/pin_keyboard.html"> diff --git a/chromium/chrome/browser/resources/settings/people_page/pin_keyboard.js b/chromium/chrome/browser/resources/settings/people_page/pin_keyboard.js deleted file mode 100644 index 4cb8d021731..00000000000 --- a/chromium/chrome/browser/resources/settings/people_page/pin_keyboard.js +++ /dev/null @@ -1,5 +0,0 @@ -// 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. - -// <include src="../../chromeos/quick_unlock/pin_keyboard.js"> diff --git a/chromium/chrome/browser/resources/settings/people_page/profile_info_browser_proxy.js b/chromium/chrome/browser/resources/settings/people_page/profile_info_browser_proxy.js index 31f4824fd3b..65808a60de6 100644 --- a/chromium/chrome/browser/resources/settings/people_page/profile_info_browser_proxy.js +++ b/chromium/chrome/browser/resources/settings/people_page/profile_info_browser_proxy.js @@ -32,12 +32,6 @@ cr.define('settings', function() { * 'profile-stats-count-ready' WebUI listener event. */ getProfileStatsCount() {} - - /** - * Returns a Promise that's true if the profile manages supervised users. - * @return {!Promise<boolean>} - */ - getProfileManagesSupervisedUsers() {} } /** @@ -53,11 +47,6 @@ cr.define('settings', function() { getProfileStatsCount() { chrome.send('getProfileStatsCount'); } - - /** @override */ - getProfileManagesSupervisedUsers() { - return cr.sendWithPromise('getProfileManagesSupervisedUsers'); - } } cr.addSingletonGetter(ProfileInfoBrowserProxyImpl); diff --git a/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.html b/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.html index c39f06d3012..7e005111b04 100644 --- a/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.html +++ b/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.html @@ -72,13 +72,13 @@ </div> </div> <div slot="button-container"> - <paper-button id="addAnotherButton" on-tap="onAddAnotherFingerprint_" + <paper-button id="addAnotherButton" on-click="onAddAnotherFingerprint_" hidden$="[[hideAddAnother_(step_)]]"> $i18n{configureFingerprintAddAnotherButton} </paper-button> <paper-button id="closeButton" - class$="[[getCloseButtonClass_(step_)]]" on-tap="onClose_"> + class$="[[getCloseButtonClass_(step_)]]" on-click="onClose_"> [[getCloseButtonText_(step_)]] </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.js b/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.js index 7922b7808ec..4b9ca548d58 100644 --- a/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.js +++ b/chromium/chrome/browser/resources/settings/people_page/setup_fingerprint_dialog.js @@ -259,7 +259,7 @@ Polymer({ */ getCloseButtonText_: function(step) { if (step == settings.FingerprintSetupStep.READY) - return this.i18n('configureFingerprintDoneButton'); + return this.i18n('done'); return this.i18n('cancel'); }, diff --git a/chromium/chrome/browser/resources/settings/people_page/setup_pin_dialog.html b/chromium/chrome/browser/resources/settings/people_page/setup_pin_dialog.html index d7a74e2a102..7cffa7ec8cb 100644 --- a/chromium/chrome/browser/resources/settings/people_page/setup_pin_dialog.html +++ b/chromium/chrome/browser/resources/settings/people_page/setup_pin_dialog.html @@ -1,3 +1,4 @@ +<link rel="import" href="chrome://resources/cr_components/chromeos/quick_unlock/pin_keyboard.html"> <link rel="import" href="chrome://resources/cr_elements/icons.html"> <link rel="import" href="chrome://resources/html/polymer.html"> @@ -6,7 +7,6 @@ <link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> <link rel="import" href="../i18n_setup.html"> <link rel="import" href="lock_screen_constants.html"> -<link rel="import" href="pin_keyboard.html"> <link rel="import" href="../settings_shared_css.html"> <dom-module id="settings-setup-pin-dialog"> @@ -28,6 +28,13 @@ --iron-icon-fill-color: var(--paper-grey-700); } + pin-keyboard { + --pin-keyboard-digit-button: { + font-size: 18px; + padding: 15px 21px; + }; + } + #pinKeyboardDiv { justify-content: center; } @@ -62,11 +69,11 @@ </div> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_"> + <paper-button class="cancel-button" on-click="onCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onPinSubmit_" + <paper-button class="action-button" on-click="onPinSubmit_" disabled$="[[!enableSubmit_]]"> <span>[[getContinueMessage_(isConfirmStep_)]]</span> </paper-button> diff --git a/chromium/chrome/browser/resources/settings/people_page/sync_account_control.html b/chromium/chrome/browser/resources/settings/people_page/sync_account_control.html new file mode 100644 index 00000000000..e59e5f06258 --- /dev/null +++ b/chromium/chrome/browser/resources/settings/people_page/sync_account_control.html @@ -0,0 +1,200 @@ +<link rel="import" href="chrome://resources/html/polymer.html"> + +<link rel="import" href="chrome://resources/cr_elements/icons.html"> +<link rel="import" href="chrome://resources/cr_elements/cr_action_menu/cr_action_menu.html"> +<link rel="import" href="chrome://resources/html/web_ui_listener_behavior.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/iron-icon/iron-icon.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/iron-icons/notification-icons.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> +<link rel="import" href="profile_info_browser_proxy.html"> +<link rel="import" href="sync_browser_proxy.html"> +<link rel="import" href="../i18n_setup.html"> +<link rel="import" href="../route.html"> +<link rel="import" href="../settings_shared_css.html"> + +<dom-module id="settings-sync-account-control"> + <template> + <style include="settings-shared"> + :host { + --sync-icon-size: 16px; + --sync-icon-border-size: 2px; + --shown-avatar-size: 40px; + } + + setting-box.middle { + /* Per spec, middle text is indented 20px in this section. */ + -webkit-margin-start: 20px; + } + + .account-icon { + border-radius: 20px; + flex-shrink: 0; + height: var(--shown-avatar-size); + width: var(--shown-avatar-size); + } + + .account-icon.small { + height: 20px; + width: 20px; + } + + #menu .dropdown-item { + padding: 12px; + } + + #menu .dropdown-item span { + -webkit-margin-start: 8px; + } + + .flex { + display: flex; + flex: 1; + flex-direction: column; + } + + #avatar-container { + position: relative; + } + + #sync-icon-container { + align-items: center; + background: var(--google-blue-500); + border: var(--sync-icon-border-size) solid white; + border-radius: 50%; + display: flex; + height: var(--sync-icon-size); + position: absolute; + right: -6px; + top: calc(var(--shown-avatar-size) - var(--sync-icon-size) - + var(--sync-icon-border-size)); + width: var(--sync-icon-size); + } + + :host-context([dir='rtl']) #sync-icon-container { + left: -6px; + right: initial; + } + + #sync-icon-container[syncing] { + background: green; + } + + #sync-icon-container iron-icon { + fill: white; + height: 12px; + margin: auto; + width: 12px; + } + + #sign-in { + min-width: 100px; + } + + #banner { + background-color: var(--google-blue-500); + display: none; + } + + #banner img { + -webkit-margin-start: 380px; + height: 100px; + margin-bottom: -12px; + margin-top: 32px; + } + + :host([showing-promo]) #banner { + display: flex; + } + + :host([showing-promo]) #promo-headers { + line-height: 1.625rem; + padding-bottom: 10px; + padding-top: 10px; + } + + :host([showing-promo]) #promo-headers #promo-title { + font-size: 1.1rem; + } + + :host([showing-promo]) #promo-headers .secondary { + font-size: 0.9rem; + } + + :host([showing-promo]) #promo-headers .separator { + display: none; + } + </style> + <div class="settings-box" id="banner"> + <img src="../images/sync_banner.svg" alt=""> + </div> + <div class="settings-box first two-line" id="promo-headers" + hidden="[[syncStatus.signedIn]]"> + <div class="start"> + <div id="promo-title">[[promoLabel]]</div> + <div class="secondary"> + [[promoSecondaryLabel]] + </div> + </div> + <div class="separator" hidden="[[shouldShowAvatarRow_]]"></div> + <paper-button class="action-button" on-click="onSigninTap_" + disabled="[[syncStatus.setupInProgress]]" id="sign-in" + hidden="[[shouldShowAvatarRow_]]"> + $i18n{peopleSignIn} + </paper-button> + </div> + <template is="dom-if" if="[[shouldShowAvatarRow_]]"> + <div class="settings-box first two-line" id="avatar-row"> + <div id="avatar-container"> + <img class="account-icon" alt="" + src="[[getAccountImageSrc_(shownAccount_.avatarImage)]]"> + <div id="sync-icon-container" syncing$="[[syncStatus.signedIn]]"> + <iron-icon icon="notification:sync"></iron-icon> + </div> + </div> + <div class="middle two-line no-min-width"> + <div class="flex text-elide" id="user-info"> + <span> + [[getNameDisplay_('$i18nPolymer{syncedToName}', + shownAccount_.fullName, syncStatus.signedIn)]] + </span> + <div class="secondary">[[shownAccount_.email]]</div> + </div> + </div> + <button is="paper-icon-button-light" id="dropdown-arrow" + on-click="onMenuButtonTap_" title="$i18n{useAnotherAccount}" + class="icon-arrow-dropdown" hidden="[[syncStatus.signedIn]]"> + </button> + <div class="separator" hidden="[[syncStatus.signedIn]]"></div> + <paper-button class="action-button" on-click="onSyncButtonTap_" + hidden="[[syncStatus.signedIn]]" + disabled="[[syncStatus.setupInProgress]]"> + [[getSubstituteLabel_( + '$i18nPolymer{syncAsName}', shownAccount_.givenName)]] + </paper-button> + <paper-button class="secondary-button" on-click="onTurnOffButtonTap_" + hidden="[[!syncStatus.signedIn]]" + disabled="[[syncStatus.setupInProgress]]"> + $i18n{turnOffSync} + </paper-button> + </div> + <template is="dom-if" if="[[!syncStatus.signedIn]]" restamp> + <dialog is="cr-action-menu" id="menu" auto-reposition> + <template is="dom-repeat" items="[[storedAccounts_]]"> + <button class="dropdown-item" on-click="onAccountTap_" slot="item"> + <img class="account-icon small" alt="" + src="[[getAccountImageSrc_(item.avatarImage)]]"> + <span>[[item.email]]</span> + </button> + </template> + <button class="dropdown-item" on-click="onSigninTap_" slot="item" + disabled="[[syncStatus.setupInProgress]]" id="sign-in-item"> + <img class="account-icon small" alt="" + src="chrome://theme/IDR_PROFILE_AVATAR_PLACEHOLDER_LARGE"> + <span>$i18n{useAnotherAccount}</span> + </button> + </dialog> + </template> + </template> + </template> + <script src="sync_account_control.js"></script> +</dom-module> diff --git a/chromium/chrome/browser/resources/settings/people_page/sync_account_control.js b/chromium/chrome/browser/resources/settings/people_page/sync_account_control.js new file mode 100644 index 00000000000..8b079fe22ac --- /dev/null +++ b/chromium/chrome/browser/resources/settings/people_page/sync_account_control.js @@ -0,0 +1,222 @@ +// Copyright 2018 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. +/** + * @fileoverview + * 'settings-sync-account-section' is the settings page containing sign-in + * settings. + */ +cr.exportPath('settings'); + +/** @const {number} */ +settings.MAX_SIGNIN_PROMO_IMPRESSION = 10; + +Polymer({ + is: 'settings-sync-account-control', + behaviors: [WebUIListenerBehavior], + properties: { + /** + * The current sync status, supplied by SyncBrowserProxy. + * @type {!settings.SyncStatus} + */ + syncStatus: Object, + + /** + * Proxy variable for syncStatus.signedIn to shield observer from being + * triggered multiple times whenever syncStatus changes. + * @private {boolean} + */ + signedIn_: { + type: Boolean, + computed: 'computeSignedIn_(syncStatus.signedIn)', + observer: 'onSignedInChanged_', + }, + + /** @private {!Array<!settings.StoredAccount>} */ + storedAccounts_: Object, + + /** @private {?settings.StoredAccount} */ + shownAccount_: Object, + + showingPromo: { + type: Boolean, + value: false, + reflectToAttribute: true, + }, + + promoLabel: String, + + promoSecondaryLabel: String, + + /** @private {boolean} */ + shouldShowAvatarRow_: { + type: Boolean, + value: false, + computed: 'computeShouldShowAvatarRow_(storedAccounts_, syncStatus,' + + 'storedAccounts_.length, syncStatus.signedIn)', + observer: 'onShouldShowAvatarRowChange_', + } + }, + + observers: [ + 'onShownAccountShouldChange_(storedAccounts_, syncStatus)', + ], + + /** @private {?settings.SyncBrowserProxy} */ + syncBrowserProxy_: null, + + /** @override */ + attached: function() { + this.syncBrowserProxy_ = settings.SyncBrowserProxyImpl.getInstance(); + this.syncBrowserProxy_.getSyncStatus().then( + this.handleSyncStatus_.bind(this)); + this.syncBrowserProxy_.getStoredAccounts().then( + this.handleStoredAccounts_.bind(this)); + this.addWebUIListener( + 'sync-status-changed', this.handleSyncStatus_.bind(this)); + this.addWebUIListener( + 'stored-accounts-updated', this.handleStoredAccounts_.bind(this)); + }, + + /** + * @return {boolean} + * @private + */ + computeSignedIn_: function() { + return !!this.syncStatus.signedIn; + }, + + /** @private */ + onSignedInChanged_: function() { + if (!this.showingPromo && !this.syncStatus.signedIn && + this.syncBrowserProxy_.getPromoImpressionCount() < + settings.MAX_SIGNIN_PROMO_IMPRESSION) { + this.showingPromo = true; + this.syncBrowserProxy_.incrementPromoImpressionCount(); + } else { + // Turn off the promo if the user is signed in. + this.showingPromo = false; + } + }, + + /** + * @param {string} label + * @param {string} name + * @return {string} + * @private + */ + getSubstituteLabel_: function(label, name) { + return loadTimeData.substituteString(label, name); + }, + + /** + * @param {string} label + * @param {string} name + * @return {string} + * @private + */ + getNameDisplay_: function(label, name) { + return this.syncStatus.signedIn ? + loadTimeData.substituteString(label, name) : + name; + }, + + /** + * @param {?string} image + * @return {string} + * @private + */ + getAccountImageSrc_: function(image) { + // image can be undefined if the account has not set an avatar photo. + return image || 'chrome://theme/IDR_PROFILE_AVATAR_PLACEHOLDER_LARGE'; + }, + + /** + * @param {!Array<!settings.StoredAccount>} accounts + * @private + */ + handleStoredAccounts_: function(accounts) { + this.storedAccounts_ = accounts; + }, + + /** + * Handler for when the sync state is pushed from the browser. + * @param {!settings.SyncStatus} syncStatus + * @private + */ + handleSyncStatus_: function(syncStatus) { + this.syncStatus = syncStatus; + }, + + /** + * @return {boolean} + * @private + */ + computeShouldShowAvatarRow_: function() { + return this.syncStatus.signedIn || this.storedAccounts_.length > 0; + }, + + /** @private */ + onSigninTap_: function() { + this.syncBrowserProxy_.startSignIn(); + + // Need to close here since one menu item also triggers this function. + if (this.$$('#menu')) { + /** @type {!CrActionMenuElement} */ (this.$$('#menu')).close(); + } + }, + + /** @private */ + onSyncButtonTap_: function() { + assert(this.shownAccount_); + this.syncBrowserProxy_.startSyncingWithEmail(this.shownAccount_.email); + }, + + /** @private */ + onTurnOffButtonTap_: function() { + /* This will route to people_page's disconnect dialog. */ + settings.navigateTo(settings.routes.SIGN_OUT); + }, + + /** @private */ + onMenuButtonTap_: function() { + const actionMenu = + /** @type {!CrActionMenuElement} */ (this.$$('#menu')); + actionMenu.showAt(assert(this.$$('#dropdown-arrow'))); + }, + + /** @private */ + onShouldShowAvatarRowChange_: function() { + // Close dropdown when avatar-row hides, so if it appears again, the menu + // won't be open by default. + const actionMenu = this.$$('#menu'); + if (!this.shouldShowAvatarRow_ && actionMenu && actionMenu.open) + actionMenu.close(); + }, + + /** + * @param {!{model: + * !{item: !settings.StoredAccount}, + * }} e + * @private + */ + onAccountTap_: function(e) { + this.shownAccount_ = e.model.item; + /** @type {!CrActionMenuElement} */ (this.$$('#menu')).close(); + }, + + /** @private */ + onShownAccountShouldChange_: function() { + if (this.syncStatus.signedIn) { + for (let i = 0; i < this.storedAccounts_.length; i++) { + if (this.storedAccounts_[i].email == this.syncStatus.signedInUsername) { + this.shownAccount_ = this.storedAccounts_[i]; + return; + } + } + } else { + this.shownAccount_ = + this.storedAccounts_ ? this.storedAccounts_[0] : null; + } + } +});
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/settings/people_page/sync_browser_proxy.js b/chromium/chrome/browser/resources/settings/people_page/sync_browser_proxy.js index 05f345f3edf..ecb2a43ed3d 100644 --- a/chromium/chrome/browser/resources/settings/people_page/sync_browser_proxy.js +++ b/chromium/chrome/browser/resources/settings/people_page/sync_browser_proxy.js @@ -10,12 +10,20 @@ cr.exportPath('settings'); /** + * @typedef {{fullName: (string|undefined), + * givenName: (string|undefined), + * email: string, + * avatarImage: (string|undefined)}} + * @see chrome/browser/ui/webui/settings/people_handler.cc + */ +settings.StoredAccount; + +/** * @typedef {{childUser: (boolean|undefined), * domain: (string|undefined), * hasError: (boolean|undefined), * hasUnrecoverableError: (boolean|undefined), * managed: (boolean|undefined), - * setupCompleted: (boolean|undefined), * setupInProgress: (boolean|undefined), * signedIn: (boolean|undefined), * signedInUsername: (string|undefined), @@ -104,6 +112,12 @@ settings.PageStatus = { }; cr.define('settings', function() { + /** + * Key to be used with localStorage. + * @type {string} + */ + const PROMO_IMPRESSION_COUNT_KEY = 'signin-promo-count'; + /** @interface */ class SyncBrowserProxy { // <if expr="not chromeos"> @@ -124,6 +138,16 @@ cr.define('settings', function() { */ manageOtherPeople() {} + /** + * @return {number} the number of times the sync account promo was shown. + */ + getPromoImpressionCount() {} + + /** + * Increment the number of times the sync account promo was shown. + */ + incrementPromoImpressionCount() {} + // </if> // <if expr="chromeos"> @@ -141,6 +165,12 @@ cr.define('settings', function() { getSyncStatus() {} /** + * Gets a list of stored accounts. + * @return {!Promise<!Array<!settings.StoredAccount>>} + */ + getStoredAccounts() {} + + /** * Function to invoke when the sync page has been navigated to. This * registers the UI as the "active" sync UI so that if the user tries to * open another sync UI, this one will be shown instead. @@ -168,6 +198,12 @@ cr.define('settings', function() { setSyncEncryption(syncPrefs) {} /** + * Start syncing with an account, specified by its email. + * @param {string} email + */ + startSyncingWithEmail(email) {} + + /** * Opens the Google Activity Controls url in a new tab. */ openActivityControlsUrl() {} @@ -193,13 +229,26 @@ cr.define('settings', function() { chrome.send('SyncSetupManageOtherPeople'); } + /** @override */ + getPromoImpressionCount() { + return parseInt( + window.localStorage.getItem(PROMO_IMPRESSION_COUNT_KEY), 10) || + 0; + } + + /** @override */ + incrementPromoImpressionCount() { + window.localStorage.setItem( + PROMO_IMPRESSION_COUNT_KEY, + (this.getPromoImpressionCount() + 1).toString()); + } + // </if> // <if expr="chromeos"> /** @override */ attemptUserExit() { return chrome.send('AttemptUserExit'); } - // </if> /** @override */ @@ -208,6 +257,11 @@ cr.define('settings', function() { } /** @override */ + getStoredAccounts() { + return cr.sendWithPromise('SyncSetupGetStoredAccounts'); + } + + /** @override */ didNavigateToSyncPage() { chrome.send('SyncSetupShowSetupUI'); } @@ -230,6 +284,11 @@ cr.define('settings', function() { } /** @override */ + startSyncingWithEmail(email) { + chrome.send('SyncSetupStartSyncingWithEmail', [email]); + } + + /** @override */ openActivityControlsUrl() { chrome.metricsPrivate.recordUserAction( 'Signin_AccountSettings_GoogleActivityControlsClicked'); diff --git a/chromium/chrome/browser/resources/settings/people_page/sync_page.html b/chromium/chrome/browser/resources/settings/people_page/sync_page.html index bea84fd3f43..5f9b6a925e1 100644 --- a/chromium/chrome/browser/resources/settings/people_page/sync_page.html +++ b/chromium/chrome/browser/resources/settings/people_page/sync_page.html @@ -88,7 +88,7 @@ on-keypress="onSubmitExistingPassphraseTap_"> </paper-input> <paper-button id="submitExistingPassphrase" - on-tap="onSubmitExistingPassphraseTap_" class="action-button" + on-click="onSubmitExistingPassphraseTap_" class="action-button" disabled="[[!existingPassphrase_]]"> $i18n{submitPassphraseButton} </paper-button> @@ -251,7 +251,7 @@ <a class="settings-box two-line inherit-color no-outline" tabindex="-1" target="_blank" href="$i18n{activityControlsUrl}" - on-tap="onActivityControlsTap_"> + on-click="onActivityControlsTap_"> <div class="start"> $i18n{personalizeGoogleServicesTitle} <div class="secondary" id="activityControlsSecondary"> @@ -294,7 +294,7 @@ <span>[[syncPrefs.fullEncryptionBody]]</span> </template> <template is="dom-if" if="[[!syncPrefs.fullEncryptionBody]]"> - <span on-tap="onLearnMoreTap_"> + <span on-click="onLearnMoreTap_"> $i18nRaw{encryptWithSyncPassphraseLabel} </span> </template> @@ -323,7 +323,7 @@ error-message="$i18n{mismatchedPassphraseError}"> </paper-input> <paper-button id="saveNewPassphrase" - on-tap="onSaveNewPassphraseTap_" class="action-button" + on-click="onSaveNewPassphraseTap_" class="action-button" disabled="[[!isSaveNewPassphraseEnabled_(passphrase_, confirmation_)]]"> $i18n{save} diff --git a/chromium/chrome/browser/resources/settings/people_page/user_list.html b/chromium/chrome/browser/resources/settings/people_page/user_list.html index 0873990c000..4ca878eef14 100644 --- a/chromium/chrome/browser/resources/settings/people_page/user_list.html +++ b/chromium/chrome/browser/resources/settings/people_page/user_list.html @@ -46,7 +46,7 @@ </template> </div> <button is="paper-icon-button-light" class="icon-clear" - on-tap="removeUser_" + on-click="removeUser_" hidden="[[shouldHideCloseButton_(disabled, item.isOwner)]]"> </button> </div> diff --git a/chromium/chrome/browser/resources/settings/people_page/users_add_user_dialog.html b/chromium/chrome/browser/resources/settings/people_page/users_add_user_dialog.html index 99850568bff..fe8cd9c784b 100644 --- a/chromium/chrome/browser/resources/settings/people_page/users_add_user_dialog.html +++ b/chromium/chrome/browser/resources/settings/people_page/users_add_user_dialog.html @@ -25,10 +25,10 @@ </paper-input> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_"> + <paper-button class="cancel-button" on-click="onCancelTap_"> $i18n{cancel} </paper-button> - <paper-button on-tap="addUser_" class="action-button" + <paper-button on-click="addUser_" class="action-button" disabled$="[[!isValid_]]"> $i18n{add} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/people_page/users_page.html b/chromium/chrome/browser/resources/settings/people_page/users_page.html index ac720c8628e..bd58da4cdd0 100644 --- a/chromium/chrome/browser/resources/settings/people_page/users_page.html +++ b/chromium/chrome/browser/resources/settings/people_page/users_page.html @@ -39,13 +39,6 @@ label="$i18n{guestBrowsingLabel}" disabled="[[isEditingDisabled_(isOwner_, isWhitelistManaged_)]]"> </settings-toggle-button> - <template is="dom-if" if="[[profileManagesSupervisedUsers]]"> - <settings-toggle-button class="continuation" - pref="{{prefs.cros.accounts.supervisedUsersEnabled}}" - label="$i18n{supervisedUsersLabel}" - disabled="[[isEditingDisabled_(isOwner_, isWhitelistManaged_)]]"> - </settings-toggle-button> - </template> <settings-toggle-button class="continuation" pref="{{prefs.cros.accounts.showUserNamesOnSignIn}}" label="$i18n{showOnSigninLabel}" @@ -66,7 +59,7 @@ <div id="add-user-button" class="list-item" hidden="[[isEditingUsersDisabled_(isOwner_, isWhitelistManaged_, prefs.cros.accounts.allowGuest.value)]]"> - <a is="action-link" class="list-button" on-tap="openAddUserDialog_"> + <a is="action-link" class="list-button" on-click="openAddUserDialog_"> $i18n{addUsers} </a> </div> diff --git a/chromium/chrome/browser/resources/settings/people_page/users_page.js b/chromium/chrome/browser/resources/settings/people_page/users_page.js index 762fb6763f8..2d14833a0dc 100644 --- a/chromium/chrome/browser/resources/settings/people_page/users_page.js +++ b/chromium/chrome/browser/resources/settings/people_page/users_page.js @@ -19,15 +19,6 @@ Polymer({ notify: true, }, - /** - * True if the current profile manages supervised users. - * Set in people-page. - */ - profileManagesSupervisedUsers: { - type: Boolean, - value: false, - }, - /** @private */ isOwner_: { type: Boolean, diff --git a/chromium/chrome/browser/resources/settings/prefs/pref_util.js b/chromium/chrome/browser/resources/settings/prefs/pref_util.js index f9ce20040b3..fd5a45a3ce8 100644 --- a/chromium/chrome/browser/resources/settings/prefs/pref_util.js +++ b/chromium/chrome/browser/resources/settings/prefs/pref_util.js @@ -18,7 +18,7 @@ cr.define('Settings.PrefUtil', function() { case chrome.settingsPrivate.PrefType.BOOLEAN: return value == 'true'; case chrome.settingsPrivate.PrefType.NUMBER: - const n = parseInt(value, 10); + const n = parseFloat(value); if (isNaN(n)) { console.error( 'Argument to stringToPrefValue for number pref ' + diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.html b/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.html index b9cdf9e4628..d8436bfe128 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.html +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.html @@ -51,18 +51,18 @@ <div slot="dialog-buttons"> <div> <!-- Left group --> <paper-button id="manuallyAddPrinterButton" class="secondary-button" - on-tap="switchToManualAddDialog_"> + on-click="switchToManualAddDialog_"> $i18n{manuallyAddPrinterButtonText} </paper-button> </div> <div> <!-- Right group --> <paper-button class="cancel-button secondary-button" - on-tap="onCancelTap_"> + on-click="onCancelTap_"> $i18n{cancel} </paper-button> <paper-button class="action-button" id="addPrinterButton" disabled="[[!canAddPrinter_(selectedPrinter)]]" - on-tap="switchToConfiguringDialog_"> + on-click="switchToConfiguringDialog_"> $i18n{addPrinterButtonText} </paper-button> </div> @@ -122,8 +122,7 @@ <div class="label">$i18n{printerAddress}</div> <div class="secondary"> <paper-input no-label-float id="printerAddressInput" - value="{{newPrinter.printerAddress}}" - on-input="onAddressChanged_"> + value="{{newPrinter.printerAddress}}"> </paper-input> </div> </div> @@ -159,32 +158,21 @@ </div> </div> </div> - <div class="search-printer-box" id="searchInProgress" hidden> - <paper-spinner-lite active></paper-spinner-lite> - <span class="spinner-comment">$i18n{searchingPrinter}</span> - </div> - <div class="search-printer-box printer-not-found" - id="searchNotFound" hidden> - <span>$i18n{printerNotFound}</span> - </div> - <div class="search-printer-box printer-found" id="searchFound" hidden> - <span>$i18n{printerFound}</span> - </div> </div> <div slot="dialog-buttons"> <div> <!-- Left group --> <paper-button class="secondary-button" - on-tap="switchToDiscoveryDialog_"> + on-click="switchToDiscoveryDialog_"> $i18n{discoverPrintersButtonText} </paper-button> </div> <div> <!-- Right group --> <paper-button class="cancel-button secondary-button" - on-tap="onCancelTap_"> + on-click="onCancelTap_"> $i18n{cancel} </paper-button> <paper-button id="addPrinterButton" class="action-button" - on-tap="addPressed_" + on-click="addPressed_" disabled="[[!canAddPrinter_(newPrinter.printerName, newPrinter.printerAddress)]]"> $i18n{addPrinterButtonText} @@ -233,9 +221,11 @@ <div class="label">$i18n{selectDriver}</div> <div class="secondary"> <paper-input class="browse-file-input" no-label-float readonly - value="[[getBaseName_(activePrinter.printerPPDPath)]]"> - <paper-button class="browse-button" suffix - on-tap="onBrowseFile_"> + value="[[getBaseName_(activePrinter.printerPPDPath)]]" + error-message="$i18n{selectDriverErrorMessage}" + invalid="[[invalidPPD]]"> + <paper-button class="browse-button" slot="suffix" + on-click="onBrowseFile_"> $i18n{selectDriverButtonText} </paper-button> </paper-input> @@ -248,14 +238,14 @@ </div> <div slot="dialog-buttons"> <paper-button class="cancel-button secondary-button" - on-tap="onCancelTap_"> + on-click="onCancelTap_"> $i18n{cancel} </paper-button> <paper-button class="action-button" id="addPrinterButton" disabled="[[!canAddPrinter_(activePrinter.ppdManufacturer, activePrinter.ppdModel, activePrinter.printerPPDPath)]]" - on-tap="switchToConfiguringDialog_"> + on-click="switchToConfiguringDialog_"> $i18n{addPrinterButtonText} </paper-button> </div> @@ -279,7 +269,7 @@ </div> <div slot="dialog-buttons"> <paper-button class="cancel-button secondary-button" - on-tap="onCancelConfiguringTap_"> + on-click="onCancelConfiguringTap_"> $i18n{cancel} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.js b/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.js index bd6a143ae4d..52ff88a5f5c 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.js +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog.js @@ -184,13 +184,6 @@ Polymer({ this.fire('open-configuring-printer-dialog'); }, - /** @private */ - onAddressChanged_: function() { - // TODO(xdai): Check if the printer address exists and then show the - // corresponding message after the API is ready. - // The format of address is: ip-address-or-hostname:port-number. - }, - /** * @param {!Event} event * @private diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog_util.html b/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog_util.html index 797f6fe0f76..3928af6bfcd 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog_util.html +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_add_printer_dialog_util.html @@ -23,8 +23,8 @@ selected="{{selectedPrinter}}"> </array-selector> <template is="dom-repeat" items="[[printers]]"> - <button class="list-item" on-tap="onSelect_"> - [[item.printerName]] [[item.printerModel]] + <button class="list-item" on-click="onSelect_"> + [[item.printerName]] </button> </template> </div> @@ -39,7 +39,11 @@ width: 270px; } - iron-dropdown .dropdown-content { + iron-dropdown { + height: 270px; + } + + iron-dropdown [slot='dropdown-content'] { background-color: white; box-shadow: 0 2px 6px var(--paper-grey-500); min-width: 128px; @@ -56,22 +60,23 @@ background-size: 24px; } </style> - <paper-input-container no-label-float on-tap="onTap_"> + <paper-input-container no-label-float on-click="onTap_"> <input is="iron-input" type="search" bind-value="{{selectedItem}}" - on-search="onInputValueChanged_" on-change="onChange_" incremental> - <button is="paper-icon-button-light" class="icon-search" suffix - id="searchIcon" hidden> + on-search="onInputValueChanged_" on-change="onChange_" incremental + slot="input"> + <button is="paper-icon-button-light" class="icon-search" + id="searchIcon" hidden slot="suffix"> </button> - <button is="paper-icon-button-light" class="icon-arrow-dropdown" suffix - id="dropdownIcon"> + <button is="paper-icon-button-light" class="icon-arrow-dropdown" + id="dropdownIcon" slot="suffix"> </button> </paper-input-container> <iron-dropdown horizontal-align="left" vertical-align="top" vertical-offset="35"> - <div class="dropdown-content"> + <div slot="dropdown-content"> <template is="dom-repeat" items="[[items]]" filter="[[filterItems_(searchTerm_)]]"> - <button class="list-item" on-tap="onSelect_">[[item]]</button> + <button class="list-item" on-click="onSelect_">[[item]]</button> </template> </div> </iron-dropdown> diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_edit_printer_dialog.html b/chromium/chrome/browser/resources/settings/printing_page/cups_edit_printer_dialog.html index 63ebb00216d..8f0c517f723 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_edit_printer_dialog.html +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_edit_printer_dialog.html @@ -100,8 +100,8 @@ <div class="secondary"> <paper-input class="browse-file-input" no-label-float readonly value="[[getBaseName_(activePrinter.printerPPDPath)]]"> - <paper-button class="browse-button" suffix - on-tap="onBrowseFile_"> + <paper-button class="browse-button" slot="suffix" + on-click="onBrowseFile_"> $i18n{selectDriverButtonText} </paper-button> </paper-input> @@ -111,10 +111,10 @@ </div> <div slot="dialog-buttons"> <paper-button class="cancel-button secondary-button" - on-tap="onCancelTap_"> + on-click="onCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onSaveTap_"> + <paper-button class="action-button" on-click="onSaveTap_"> $i18n{editPrinterButtonText} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_printer_shared_css.html b/chromium/chrome/browser/resources/settings/printing_page/cups_printer_shared_css.html index 029074eb08d..026ed96e938 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_printer_shared_css.html +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_printer_shared_css.html @@ -89,7 +89,7 @@ padding: 0 24px; text-align: start; width: 100%; - @apply(--settings-actionable); + @apply --settings-actionable; } .list-item:focus { diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_printers.html b/chromium/chrome/browser/resources/settings/printing_page/cups_printers.html index 3df538af0ea..8fe645217ad 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_printers.html +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_printers.html @@ -46,6 +46,14 @@ width: 350px; } + #noSearchResultsMessage { + color: var(--md-loading-message-color); + font-size: 16px; + font-weight: 500; + margin-top: 80px; + text-align: center; + } + #addPrinterErrorMessage { display: flex; justify-content: space-around; @@ -68,7 +76,7 @@ </div> </div> <paper-button class="primary-button" id="addPrinter" - on-tap="onAddPrinterTap_" disabled="[[!canAddPrinter_]]"> + on-click="onAddPrinterTap_" disabled="[[!canAddPrinter_]]"> $i18n{addCupsPrinter} </paper-button> </div> @@ -88,6 +96,11 @@ search-term="[[searchTerm]]"> </settings-cups-printers-list> + <div id="noSearchResultsMessage" + hidden="[[!showNoSearchResultsMessage_(searchTerm)]]"> + $i18n{noSearchResults} + </div> + <div id="message"> <div class="center" id="addPrinterDoneMessage" hidden> $i18n{printerAddedSuccessfulMessage} diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_printers.js b/chromium/chrome/browser/resources/settings/printing_page/cups_printers.js index f5c6e55c690..055f4a807c5 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_printers.js +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_printers.js @@ -186,4 +186,18 @@ Polymer({ }); }, + /** + * @param {string} searchTerm + * @return {boolean} If the 'no-search-results-found' string should be shown. + * @private + */ + showNoSearchResultsMessage_: function(searchTerm) { + if (!searchTerm || !this.printers.length) + return false; + searchTerm = searchTerm.toLowerCase(); + return !this.printers.some(printer => { + return printer.printerName.toLowerCase().includes(searchTerm); + }); + }, + }); diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_printers_list.html b/chromium/chrome/browser/resources/settings/printing_page/cups_printers_list.html index 8c4a1d51ac0..353742c55e2 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_printers_list.html +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_printers_list.html @@ -15,10 +15,10 @@ </style> <dialog is="cr-action-menu"> - <button class="dropdown-item" on-tap="onEditTap_"> + <button slot="item" class="dropdown-item" on-click="onEditTap_"> $i18n{editPrinter} </button> - <button class="dropdown-item" on-tap="onRemoveTap_"> + <button slot="item" class="dropdown-item" on-click="onRemoveTap_"> $i18n{removePrinter} </button> </dialog> @@ -29,7 +29,7 @@ <div class="printer-name text-elide">[[item.printerName]]</div> <!--TODO(xdai): Add icon for enterprise CUPS printer. --> <button is="paper-icon-button-light" class="icon-more-vert" - on-tap="onOpenActionMenuTap_" title="$i18n{moreActions}"> + on-click="onOpenActionMenuTap_" title="$i18n{moreActions}"> </button> </div> </template> diff --git a/chromium/chrome/browser/resources/settings/printing_page/cups_set_manufacturer_model_behavior.js b/chromium/chrome/browser/resources/settings/printing_page/cups_set_manufacturer_model_behavior.js index 84508310b53..43f805bf3f8 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/cups_set_manufacturer_model_behavior.js +++ b/chromium/chrome/browser/resources/settings/printing_page/cups_set_manufacturer_model_behavior.js @@ -16,15 +16,16 @@ const SetManufacturerModelBehavior = { notify: true, }, - /** @type {?Array<string>} */ - manufacturerList: { - type: Array, + invalidPPD: { + type: Boolean, + value: false, }, /** @type {?Array<string>} */ - modelList: { - type: Array, - }, + manufacturerList: Array, + + /** @type {?Array<string>} */ + modelList: Array, }, observers: [ @@ -86,11 +87,12 @@ const SetManufacturerModelBehavior = { }, /** - * @param {string} path + * @param {string} path The full path to the selected PPD file * @private */ printerPPDPathChanged_: function(path) { this.set('activePrinter.printerPPDPath', path); + this.invalidPPD = !path; }, /** diff --git a/chromium/chrome/browser/resources/settings/printing_page/printing_page.html b/chromium/chrome/browser/resources/settings/printing_page/printing_page.html index 90e18fbb2e5..6513b2f9f1a 100644 --- a/chromium/chrome/browser/resources/settings/printing_page/printing_page.html +++ b/chromium/chrome/browser/resources/settings/printing_page/printing_page.html @@ -22,7 +22,7 @@ <neon-animatable route-path="default"> <if expr="chromeos"> <div id="cupsPrinters" class="settings-box first" - on-tap="onTapCupsPrinters_" actionable> + on-click="onTapCupsPrinters_" actionable> <div class="start">$i18n{cupsPrintersTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{cupsPrintersTitle}"></button> @@ -30,14 +30,14 @@ </if> <if expr="not chromeos"> <div class="settings-box first" - on-tap="onTapLocalPrinters_" actionable> + on-click="onTapLocalPrinters_" actionable> <div class="start">$i18n{localPrintersTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{localPrintersTitle}"></button> </div> </if> <div id="cloudPrinters" class="settings-box" - on-tap="onTapCloudPrinters_" actionable> + on-click="onTapCloudPrinters_" actionable> <div class="start">$i18n{cloudPrintersTitle}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{cloudPrintersTitle}"></button> diff --git a/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.html b/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.html index 1ba4c86e60b..d0bd4cfddfd 100644 --- a/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.html +++ b/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.html @@ -9,7 +9,6 @@ <link rel="import" href="chrome://resources/polymer/v1_0/paper-button/paper-button.html"> <link rel="import" href="chrome://resources/polymer/v1_0/paper-icon-button/paper-icon-button-light.html"> <link rel="import" href="../clear_browsing_data_dialog/clear_browsing_data_dialog.html"> -<link rel="import" href="../clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.html"> <link rel="import" href="../controls/settings_toggle_button.html"> <link rel="import" href="../lifetime_browser_proxy.html"> <link rel="import" href="../route.html"> @@ -40,16 +39,9 @@ <style include="settings-shared"> </style> <template is="dom-if" if="[[showClearBrowsingDataDialog_]]" restamp> - <template is="dom-if" if="[[!tabsInCbd_]]" restamp> - <settings-clear-browsing-data-dialog prefs="{{prefs}}" - on-close="onDialogClosed_"> - </settings-clear-browsing-data-dialog> - </template> - <template is="dom-if" if="[[tabsInCbd_]]" restamp> - <settings-clear-browsing-data-dialog-tabs prefs="{{prefs}}" - on-close="onDialogClosed_"> - </settings-clear-browsing-data-dialog-tabs> - </template> + <settings-clear-browsing-data-dialog prefs="{{prefs}}" + on-close="onDialogClosed_"> + </settings-clear-browsing-data-dialog> </template> <template id="doNotTrackDialogIf" is="dom-if" if="[[showDoNotTrackDialog_]]" notify-dom-change> @@ -60,11 +52,11 @@ <div slot="body">$i18nRaw{doNotTrackDialogMessage}</div> <div slot="button-container"> <paper-button class="cancel-button" - on-tap="onDoNotTrackDialogCancel_"> + on-click="onDoNotTrackDialogCancel_"> $i18n{cancel} </paper-button> <paper-button class="action-button" - on-tap="onDoNotTrackDialogConfirm_"> + on-click="onDoNotTrackDialogConfirm_"> $i18n{confirm} </paper-button> </div> @@ -141,7 +133,7 @@ </if> <if expr="use_nss_certs or is_win or is_macosx"> <div id="manageCertificates" class="settings-box two-line" - actionable on-tap="onManageCertificatesTap_"> + actionable on-click="onManageCertificatesTap_"> <div class="start"> $i18n{manageCertificates} <div class="secondary" id="manageCertificatesSecondary"> @@ -162,7 +154,7 @@ </if> <div id="site-settings-subpage-trigger" class="settings-box two-line" actionable - on-tap="onSiteSettingsTap_"> + on-click="onSiteSettingsTap_"> <div class="start"> [[siteSettingsPageTitle_()]] <div class="secondary" id="siteSettingsSecondary"> @@ -174,7 +166,7 @@ aria-describedby="siteSettingsSecondary"></button> </div> <div class="settings-box two-line" id="clearBrowsingData" - on-tap="onClearBrowsingDataTap_" actionable> + on-click="onClearBrowsingDataTap_" actionable> <div class="start"> $i18n{clearBrowsingData} <div class="secondary" id="clearBrowsingDataSecondary"> @@ -265,7 +257,7 @@ label="$i18n{thirdPartyCookie}" sub-label="$i18n{thirdPartyCookieSublabel}"> </settings-toggle-button> - <div class="settings-box" actionable on-tap="onSiteDataTap_"> + <div class="settings-box" actionable on-click="onSiteDataTap_"> <div class="start" id="cookiesLink"> $i18n{siteSettingsCookieLink} </div> @@ -375,6 +367,21 @@ </category-setting-exceptions> </settings-subpage> </template> + <template is="dom-if" if="[[enableSensorsContentSetting_]]" no-search> + <template is="dom-if" route-path="/content/sensors" no-search> + <settings-subpage page-title="$i18n{siteSettingsSensors}"> + <category-default-setting + toggle-off-label="$i18n{siteSettingsSensorsBlock}" + toggle-on-label="$i18n{siteSettingsSensorsAllow}" + category="{{ContentSettingsTypes.SENSORS}}"> + </category-default-setting> + <category-setting-exceptions + category="{{ContentSettingsTypes.SENSORS}}" read-only-list + block-header="$i18n{siteSettingsBlock}"> + </category-setting-exceptions> + </settings-subpage> + </template> + </template> <template is="dom-if" route-path="/content/notifications" no-search> <settings-subpage page-title="$i18n{siteSettingsCategoryNotifications}"> <category-default-setting @@ -479,7 +486,7 @@ <template is="dom-if" route-path="/cookies/detail" no-search> <settings-subpage page-title="[[pageTitle]]"> <paper-button slot="subpage-title-extra" class="secondary-button" - on-tap="onRemoveAllCookiesFromSite_"> + on-click="onRemoveAllCookiesFromSite_"> $i18n{siteSettingsCookieRemoveAll} </paper-button> <site-data-details-subpage page-title="{{pageTitle}}"> diff --git a/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.js b/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.js index e55ac46515f..abb969d0d3b 100644 --- a/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.js +++ b/chromium/chrome/browser/resources/settings/privacy_page/privacy_page.js @@ -80,14 +80,6 @@ Polymer({ showClearBrowsingDataDialog_: Boolean, /** @private */ - tabsInCbd_: { - type: Boolean, - value: function() { - return loadTimeData.getBoolean('tabsInCbd'); - } - }, - - /** @private */ showDoNotTrackDialog_: { type: Boolean, value: false, @@ -128,6 +120,15 @@ Polymer({ } }, + /** @private */ + enableSensorsContentSetting_: { + type: Boolean, + readOnly: true, + value: function() { + return loadTimeData.getBoolean('enableSensorsContentSetting'); + } + }, + /** @private {!Map<string, string>} */ focusConfig_: { type: Object, @@ -282,7 +283,7 @@ Polymer({ /** @private */ onDialogClosed_: function() { - settings.navigateToPreviousRoute(); + settings.navigateTo(settings.routes.CLEAR_BROWSER_DATA.parent); cr.ui.focusWithoutInk(assert(this.$.clearBrowsingDataTrigger)); }, @@ -337,16 +338,21 @@ Polymer({ // </if> /** - * @param {boolean} enabled Whether reporting is enabled or not. + * @param {!SberPrefState} sberPrefState SBER enabled and managed state. * @private */ - setSafeBrowsingExtendedReporting_: function(enabled) { + setSafeBrowsingExtendedReporting_: function(sberPrefState) { // Ignore the next change because it will happen when we set the pref. - this.safeBrowsingExtendedReportingPref_ = { + const pref = { key: '', type: chrome.settingsPrivate.PrefType.BOOLEAN, - value: enabled, + value: sberPrefState.enabled, }; + if (sberPrefState.managed) { + pref.enforcement = chrome.settingsPrivate.Enforcement.ENFORCED; + pref.controlledBy = chrome.settingsPrivate.ControlledBy.USER_POLICY; + } + this.safeBrowsingExtendedReportingPref_ = pref; }, /** diff --git a/chromium/chrome/browser/resources/settings/privacy_page/privacy_page_browser_proxy.js b/chromium/chrome/browser/resources/settings/privacy_page/privacy_page_browser_proxy.js index e5037cfef21..63790dfadb2 100644 --- a/chromium/chrome/browser/resources/settings/privacy_page/privacy_page_browser_proxy.js +++ b/chromium/chrome/browser/resources/settings/privacy_page/privacy_page_browser_proxy.js @@ -7,6 +7,9 @@ /** @typedef {{enabled: boolean, managed: boolean}} */ let MetricsReporting; +/** @typedef {{enabled: boolean, managed: boolean}} */ +let SberPrefState; + cr.define('settings', function() { /** @interface */ class PrivacyPageBrowserProxy { @@ -25,7 +28,7 @@ cr.define('settings', function() { // </if> - /** @return {!Promise<boolean>} */ + /** @return {!Promise<!SberPrefState>} */ getSafeBrowsingExtendedReporting() {} /** @param {boolean} enabled */ diff --git a/chromium/chrome/browser/resources/settings/reset_page/powerwash_dialog.html b/chromium/chrome/browser/resources/settings/reset_page/powerwash_dialog.html index 1459a9afe0e..98a82fd5991 100644 --- a/chromium/chrome/browser/resources/settings/reset_page/powerwash_dialog.html +++ b/chromium/chrome/browser/resources/settings/reset_page/powerwash_dialog.html @@ -22,10 +22,10 @@ </span> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_" + <paper-button class="cancel-button" on-click="onCancelTap_" id="cancel">$i18n{cancel}</paper-button> <paper-button class="action-button" id="powerwash" - on-tap="onRestartTap_">$i18n{powerwashDialogButton}</paper-button> + on-click="onRestartTap_">$i18n{powerwashDialogButton}</paper-button> </div> </dialog> </template> diff --git a/chromium/chrome/browser/resources/settings/reset_page/reset_page.html b/chromium/chrome/browser/resources/settings/reset_page/reset_page.html index 82b9c6cf022..d45c9976996 100644 --- a/chromium/chrome/browser/resources/settings/reset_page/reset_page.html +++ b/chromium/chrome/browser/resources/settings/reset_page/reset_page.html @@ -17,6 +17,7 @@ <if expr="_google_chrome and is_win"> <link rel="import" href="../chrome_cleanup_page/chrome_cleanup_page.html"> +<link rel="import" href="../incompatible_applications_page/incompatible_applications_page.html"> </if> <dom-module id="settings-reset-page"> @@ -25,7 +26,7 @@ <settings-animated-pages id="reset-pages" section="reset"> <neon-animatable route-path="default"> <div class="settings-box first two-line" id="resetProfile" - on-tap="onShowResetProfileDialog_" actionable> + on-click="onShowResetProfileDialog_" actionable> <div class="start"> $i18n{resetTrigger} <div class="secondary" id="resetProfileSecondary"> @@ -44,7 +45,7 @@ </template> <if expr="chromeos"> <div class="settings-box two-line" id="powerwash" actionable - on-tap="onShowPowerwashDialog_" hidden="[[!allowPowerwash_]]"> + on-click="onShowPowerwashDialog_" hidden="[[!allowPowerwash_]]"> <div class="start"> $i18n{powerwashTitle} <div class="secondary" id="powerwashSecondary"> @@ -62,20 +63,28 @@ </if> <if expr="_google_chrome and is_win"> <template is="dom-if" if="[[userInitiatedCleanupsEnabled_]]" restamp> - <div class="settings-box two-line" id="chromeCleanupSubpageTrigger" - on-tap="onChromeCleanupTap_" actionable> - <div class="start"> - $i18n{resetCleanupComputerTrigger} - <div class="secondary" id="chromeCleanupSecondary"> - $i18n{resetCleanupComputerTriggerDescription} - </div> - </div> + <div class="settings-box" id="chromeCleanupSubpageTrigger" + on-click="onChromeCleanupTap_" actionable> + <div class="start">$i18n{resetCleanupComputerTrigger}</div> <button id="chromeCleanupArrow" is="paper-icon-button-light" class="subpage-arrow" aria-label="$i18n{resetCleanupComputerTrigger}" aria-describedby="chromeCleanupSecondary"></button> </div> </template> + <template is="dom-if" if="[[showIncompatibleApplications_]]" restamp> + <div class="settings-box" + id="incompatibleApplicationsSubpageTrigger" + on-click="onIncompatibleApplicationsTap_" actionable> + <div class="start"> + $i18n{incompatibleApplicationsResetCardTitle} + </div> + <button is="paper-icon-button-light" + class="subpage-arrow" + aria-label="$i18n{incompatibleApplicationsResetCardTitle}" + aria-describedby="incompatibleApplicationsSecondary"></button> + </div> + </template> </if> </neon-animatable> <if expr="_google_chrome and is_win"> @@ -89,6 +98,16 @@ </settings-subpage> </template> </template> + <template is="dom-if" if="[[showIncompatibleApplications_]]"> + <template is="dom-if" route-path="/incompatibleApplications"> + <settings-subpage id="incompatibleApplicationsSubpage" + associated-control="[[$$('#incompatibleApplicationsSubpageTrigger')]]" + page-title="$i18n{incompatibleApplicationsResetCardTitle}"> + <settings-incompatible-applications-page> + </settings-incompatible-applications-page> + </settings-subpage> + </template> + </template> </if> </settings-animated-pages> </template> diff --git a/chromium/chrome/browser/resources/settings/reset_page/reset_page.js b/chromium/chrome/browser/resources/settings/reset_page/reset_page.js index fb5c2229223..10082a0b8f8 100644 --- a/chromium/chrome/browser/resources/settings/reset_page/reset_page.js +++ b/chromium/chrome/browser/resources/settings/reset_page/reset_page.js @@ -40,6 +40,14 @@ Polymer({ return loadTimeData.getBoolean('userInitiatedCleanupsEnabled'); }, }, + + /** @private */ + showIncompatibleApplications_: { + type: Boolean, + value: function() { + return loadTimeData.getBoolean('showIncompatibleApplications'); + }, + }, // </if> }, @@ -87,9 +95,15 @@ Polymer({ // </if> // <if expr="_google_chrome and is_win"> + /** @private */ onChromeCleanupTap_: function() { settings.navigateTo(settings.routes.CHROME_CLEANUP); }, + + /** @private */ + onIncompatibleApplicationsTap_: function() { + settings.navigateTo(settings.routes.INCOMPATIBLE_APPLICATIONS); + }, // </if> }); diff --git a/chromium/chrome/browser/resources/settings/reset_page/reset_profile_banner.html b/chromium/chrome/browser/resources/settings/reset_page/reset_profile_banner.html index 3f49fa4eb03..aed186e6fab 100644 --- a/chromium/chrome/browser/resources/settings/reset_page/reset_profile_banner.html +++ b/chromium/chrome/browser/resources/settings/reset_page/reset_profile_banner.html @@ -19,10 +19,10 @@ </span> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onOkTap_" id="ok"> + <paper-button class="cancel-button" on-click="onOkTap_" id="ok"> $i18n{ok} </paper-button> - <paper-button class="action-button" on-tap="onResetTap_" id="reset"> + <paper-button class="action-button" on-click="onResetTap_" id="reset"> $i18n{resetProfileBannerButton} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.html b/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.html index ef12529acb3..be6154e2f30 100644 --- a/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.html +++ b/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.html @@ -35,11 +35,11 @@ <div slot="button-container"> <paper-spinner-lite id="resetSpinner" active="[[clearingInProgress_]]"> </paper-spinner-lite> - <paper-button class="cancel-button" on-tap="onCancelTap_" + <paper-button class="cancel-button" on-click="onCancelTap_" id="cancel" disabled="[[clearingInProgress_]]"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onResetTap_" + <paper-button class="action-button" on-click="onResetTap_" id="reset" disabled="[[clearingInProgress_]]"> $i18n{resetPageCommit} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.js b/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.js index 683e2692f9e..22066b37961 100644 --- a/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.js +++ b/chromium/chrome/browser/resources/settings/reset_page/reset_profile_dialog.js @@ -77,7 +77,7 @@ Polymer({ }); this.$$('paper-checkbox a') - .addEventListener('tap', this.onShowReportedSettingsTap_.bind(this)); + .addEventListener('click', this.onShowReportedSettingsTap_.bind(this)); // Prevent toggling of the checkbox when hitting the "Enter" key on the // link. this.$$('paper-checkbox a').addEventListener('keydown', function(e) { diff --git a/chromium/chrome/browser/resources/settings/route.js b/chromium/chrome/browser/resources/settings/route.js index de01fd22a9c..68183214b91 100644 --- a/chromium/chrome/browser/resources/settings/route.js +++ b/chromium/chrome/browser/resources/settings/route.js @@ -36,6 +36,7 @@ * FONTS: (undefined|!settings.Route), * GOOGLE_ASSISTANT: (undefined|!settings.Route), * IMPORT_DATA: (undefined|!settings.Route), + * INCOMPATIBLE_APPLICATIONS: (undefined|!settings.Route), * INPUT_METHODS: (undefined|!settings.Route), * INTERNET: (undefined|!settings.Route), * INTERNET_NETWORKS: (undefined|!settings.Route), @@ -73,6 +74,7 @@ * SITE_SETTINGS_HANDLERS: (undefined|!settings.Route), * SITE_SETTINGS_IMAGES: (undefined|!settings.Route), * SITE_SETTINGS_JAVASCRIPT: (undefined|!settings.Route), + * SITE_SETTINGS_SENSORS: (undefined|!settings.Route), * SITE_SETTINGS_SOUND: (undefined|!settings.Route), * SITE_SETTINGS_LOCATION: (undefined|!settings.Route), * SITE_SETTINGS_MICROPHONE: (undefined|!settings.Route), @@ -317,6 +319,7 @@ cr.define('settings', function() { r.SITE_SETTINGS_IMAGES = r.SITE_SETTINGS.createChild('images'); r.SITE_SETTINGS_JAVASCRIPT = r.SITE_SETTINGS.createChild('javascript'); r.SITE_SETTINGS_SOUND = r.SITE_SETTINGS.createChild('sound'); + r.SITE_SETTINGS_SENSORS = r.SITE_SETTINGS.createChild('sensors'); r.SITE_SETTINGS_LOCATION = r.SITE_SETTINGS.createChild('location'); r.SITE_SETTINGS_MICROPHONE = r.SITE_SETTINGS.createChild('microphone'); r.SITE_SETTINGS_NOTIFICATIONS = @@ -388,6 +391,10 @@ cr.define('settings', function() { if (loadTimeData.getBoolean('userInitiatedCleanupsEnabled')) { r.CHROME_CLEANUP = r.RESET.createChild('/cleanup'); } + if (loadTimeData.getBoolean('showIncompatibleApplications')) { + r.INCOMPATIBLE_APPLICATIONS = + r.RESET.createChild('/incompatibleApplications'); + } // </if> } } diff --git a/chromium/chrome/browser/resources/settings/search_engines_page/omnibox_extension_entry.html b/chromium/chrome/browser/resources/settings/search_engines_page/omnibox_extension_entry.html index aa5677369c5..89475dacb36 100644 --- a/chromium/chrome/browser/resources/settings/search_engines_page/omnibox_extension_entry.html +++ b/chromium/chrome/browser/resources/settings/search_engines_page/omnibox_extension_entry.html @@ -35,14 +35,16 @@ </div> <div class="keyword-column">[[engine.keyword]]</div> <button is="paper-icon-button-light" class="icon-more-vert" - on-tap="onDotsTap_" title="$i18n{moreActions}" focus-row-control + on-click="onDotsTap_" title="$i18n{moreActions}" focus-row-control focus-type="menu"> </button> <dialog is="cr-action-menu"> - <button class="dropdown-item" on-tap="onManageTap_" id="manage"> + <button slot="item" class="dropdown-item" on-click="onManageTap_" + id="manage"> $i18n{searchEnginesManageExtension} </button> - <button class="dropdown-item" on-tap="onDisableTap_" id="disable"> + <button slot="item" class="dropdown-item" on-click="onDisableTap_" + id="disable"> $i18n{disable} </button> </dialog> diff --git a/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_dialog.html b/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_dialog.html index a2788c608ec..a8e0591c515 100644 --- a/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_dialog.html +++ b/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_dialog.html @@ -44,10 +44,10 @@ </paper-input> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="cancel_" id="cancel"> + <paper-button class="cancel-button" on-click="cancel_" id="cancel"> $i18n{cancel}</paper-button> <paper-button id="actionButton" class="action-button" - on-tap="onActionButtonTap_"> + on-click="onActionButtonTap_"> [[actionButtonText_]] </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_entry.html b/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_entry.html index f4b23582e1c..5f0f410817a 100644 --- a/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_entry.html +++ b/chromium/chrome/browser/resources/settings/search_engines_page/search_engine_entry.html @@ -54,19 +54,19 @@ <div id="keyword-column"><div>[[engine.keyword]]</div></div> <div id="url-column" class="text-elide">[[engine.url]]</div> <button is="paper-icon-button-light" class="icon-more-vert" - on-tap="onDotsTap_" title="$i18n{moreActions}" focus-row-control + on-click="onDotsTap_" title="$i18n{moreActions}" focus-row-control focus-type="cr-menu-button"> </button> <dialog is="cr-action-menu"> - <button class="dropdown-item" on-tap="onMakeDefaultTap_" + <button slot="item" class="dropdown-item" on-click="onMakeDefaultTap_" hidden$="[[!engine.canBeDefault]]" id="makeDefault"> $i18n{searchEnginesMakeDefault} </button> - <button class="dropdown-item" on-tap="onEditTap_" + <button slot="item" class="dropdown-item" on-click="onEditTap_" hidden$="[[!engine.canBeEdited]]" id="edit"> $i18n{edit} </button> - <button class="dropdown-item" on-tap="onDeleteTap_" + <button slot="item" class="dropdown-item" on-click="onDeleteTap_" hidden$="[[!engine.canBeRemoved]]" id="delete"> $i18n{searchEnginesRemoveFromList} </button> diff --git a/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_list.html b/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_list.html index 507bf4875ad..1e6af9d9737 100644 --- a/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_list.html +++ b/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_list.html @@ -23,7 +23,7 @@ } #outer { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; } settings-search-engine-entry { diff --git a/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_page.html b/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_page.html index eb06d3cd345..44638d4ee43 100644 --- a/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_page.html +++ b/chromium/chrome/browser/resources/settings/search_engines_page/search_engines_page.html @@ -19,7 +19,7 @@ .extension-engines, #noOtherEngines, .no-search-results { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; } settings-omnibox-extension-entry { @@ -43,7 +43,7 @@ <div class="settings-box first"> <h2 class="start">$i18n{searchEnginesOther}</h2> <paper-button class="secondary-button header-aligned-button" - on-tap="onAddSearchEngineTap_" id="addSearchEngine"> + on-click="onAddSearchEngineTap_" id="addSearchEngine"> $i18n{add} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/search_page/search_page.html b/chromium/chrome/browser/resources/settings/search_page/search_page.html index 1e9f88f1e7c..d32e97c489c 100644 --- a/chromium/chrome/browser/resources/settings/search_page/search_page.html +++ b/chromium/chrome/browser/resources/settings/search_page/search_page.html @@ -84,7 +84,7 @@ <!-- Manage search engines --> <div id="engines-subpage-trigger" class="settings-box" - on-tap="onManageSearchEnginesTap_" actionable> + on-click="onManageSearchEnginesTap_" actionable> <div class="start"> $i18n{searchEnginesManage} </div> @@ -96,7 +96,7 @@ <!-- Google Assistant --> <template is="dom-if" if="[[voiceInteractionFeatureEnabled_]]"> <div id="assistant-subpage-trigger" class="settings-box two-line" - on-tap="onGoogleAssistantTap_" actionable> + on-click="onGoogleAssistantTap_" actionable> <div class="start"> $i18n{searchGoogleAssistant} <div class="secondary"> @@ -111,7 +111,7 @@ <template is="dom-if" if="[[!assistantOn_]]"> <div class="separator"></div> <paper-button id="enable" class="secondary-button" - on-tap="onAssistantTurnOnTap_" + on-click="onAssistantTurnOnTap_" aria-label="$i18n{searchPageTitle}" aria-describedby="secondaryText"> $i18n{assistantTurnOn} diff --git a/chromium/chrome/browser/resources/settings/search_settings.js b/chromium/chrome/browser/resources/settings/search_settings.js index 684aed657e9..71971fa8246 100644 --- a/chromium/chrome/browser/resources/settings/search_settings.js +++ b/chromium/chrome/browser/resources/settings/search_settings.js @@ -17,18 +17,6 @@ cr.exportPath('settings'); settings.SearchResult; cr.define('settings', function() { - /** @type {string} */ - const WRAPPER_CSS_CLASS = 'search-highlight-wrapper'; - - /** @type {string} */ - const ORIGINAL_CONTENT_CSS_CLASS = 'search-highlight-original-content'; - - /** @type {string} */ - const HIT_CSS_CLASS = 'search-highlight-hit'; - - /** @type {string} */ - const SEARCH_BUBBLE_CSS_CLASS = 'search-bubble'; - /** * A CSS attribute indicating that a node should be ignored during searching. * @type {string} @@ -65,59 +53,8 @@ cr.define('settings', function() { * @private */ function findAndRemoveHighlights_(node) { - const wrappers = node.querySelectorAll('* /deep/ .' + WRAPPER_CSS_CLASS); - - for (let i = 0; i < wrappers.length; i++) { - const wrapper = wrappers[i]; - const originalNode = - wrapper.querySelector('.' + ORIGINAL_CONTENT_CSS_CLASS); - wrapper.parentElement.replaceChild(originalNode.firstChild, wrapper); - } - - const searchBubbles = - node.querySelectorAll('* /deep/ .' + SEARCH_BUBBLE_CSS_CLASS); - for (let j = 0; j < searchBubbles.length; j++) - searchBubbles[j].remove(); - } - - /** - * Applies the highlight UI (yellow rectangle) around all matches in |node|. - * @param {!Node} node The text node to be highlighted. |node| ends up - * being removed from the DOM tree. - * @param {!Array<string>} tokens The string tokens after splitting on the - * relevant regExp. Even indices hold text that doesn't need highlighting, - * odd indices hold the text to be highlighted. For example: - * const r = new RegExp('(foo)', 'i'); - * 'barfoobar foo bar'.split(r) => ['bar', 'foo', 'bar ', 'foo', ' bar'] - * @private - */ - function highlight_(node, tokens) { - const wrapper = document.createElement('span'); - wrapper.classList.add(WRAPPER_CSS_CLASS); - // Use existing node as placeholder to determine where to insert the - // replacement content. - node.parentNode.replaceChild(wrapper, node); - - // Keep the existing node around for when the highlights are removed. The - // existing text node might be involved in data-binding and therefore should - // not be discarded. - const span = document.createElement('span'); - span.classList.add(ORIGINAL_CONTENT_CSS_CLASS); - span.style.display = 'none'; - span.appendChild(node); - wrapper.appendChild(span); - - for (let i = 0; i < tokens.length; ++i) { - if (i % 2 == 0) { - wrapper.appendChild(document.createTextNode(tokens[i])); - } else { - const hitSpan = document.createElement('span'); - hitSpan.classList.add(HIT_CSS_CLASS); - hitSpan.style.backgroundColor = '#ffeb3b'; // --var(--paper-yellow-500) - hitSpan.textContent = tokens[i]; - wrapper.appendChild(hitSpan); - } - } + cr.search_highlight_utils.findAndRemoveHighlights(node); + cr.search_highlight_utils.findAndRemoveBubbles(node); } /** @@ -163,8 +100,10 @@ cr.define('settings', function() { // displayed within an <option>. // TODO(dpapad): highlight <select> controls with a search bubble // instead. - if (node.parentNode.nodeName != 'OPTION') - highlight_(node, textContent.split(request.regExp)); + if (node.parentNode.nodeName != 'OPTION') { + cr.search_highlight_utils.highlight( + node, textContent.split(request.regExp)); + } } // Returning early since TEXT_NODE nodes never have children. return; @@ -189,44 +128,6 @@ cr.define('settings', function() { } /** - * Highlights the HTML control that triggers a subpage, by displaying a search - * bubble. - * @param {!HTMLElement} element The element to be highlighted. - * @param {string} rawQuery The search query. - * @private - */ - function highlightAssociatedControl_(element, rawQuery) { - let searchBubble = element.querySelector('.' + SEARCH_BUBBLE_CSS_CLASS); - // If the associated control has already been highlighted due to another - // match on the same subpage, there is no need to do anything. - if (searchBubble) - return; - - searchBubble = document.createElement('div'); - searchBubble.classList.add(SEARCH_BUBBLE_CSS_CLASS); - const innards = document.createElement('div'); - innards.classList.add('search-bubble-innards', 'text-elide'); - innards.textContent = rawQuery; - searchBubble.appendChild(innards); - element.appendChild(searchBubble); - - // Dynamically position the bubble at the edge the associated control - // element. - const updatePosition = function() { - searchBubble.style.top = element.offsetTop + - (innards.classList.contains('above') ? -searchBubble.offsetHeight : - element.offsetHeight) + - 'px'; - }; - updatePosition(); - - searchBubble.addEventListener('mouseover', function() { - innards.classList.toggle('above'); - updatePosition(); - }); - } - - /** * Finds and makes visible the <settings-section> parent of |node|. * @param {!Node} node * @param {string} rawQuery @@ -253,8 +154,10 @@ cr.define('settings', function() { // Need to add the search bubble after the parent SETTINGS-SECTION has // become visible, otherwise |offsetWidth| returns zero. - if (associatedControl) - highlightAssociatedControl_(associatedControl, rawQuery); + if (associatedControl) { + cr.search_highlight_utils.highlightControlWithBubble( + associatedControl, rawQuery); + } } /** @abstract */ diff --git a/chromium/chrome/browser/resources/settings/settings.html b/chromium/chrome/browser/resources/settings/settings.html index 8732184b614..2f8a74473c8 100644 --- a/chromium/chrome/browser/resources/settings/settings.html +++ b/chromium/chrome/browser/resources/settings/settings.html @@ -10,6 +10,8 @@ html { background-color: #f1f1f1; overflow: hidden; + /* Remove 300ms delay for 'click' event, when using touch interface. */ + touch-action: manipulation; } .loading { @@ -20,6 +22,7 @@ </head> <body> <settings-ui></settings-ui> + <link rel="stylesheet" href="chrome://resources/css/md_colors.css"> <link rel="stylesheet" href="chrome://resources/css/text_defaults_md.css"> <link rel="import" href="chrome://resources/html/polymer.html"> <link rel="import" href="settings_ui/settings_ui.html"> diff --git a/chromium/chrome/browser/resources/settings/settings_main/settings_main.html b/chromium/chrome/browser/resources/settings/settings_main/settings_main.html index fcbd63786ef..31e3040c21e 100644 --- a/chromium/chrome/browser/resources/settings/settings_main/settings_main.html +++ b/chromium/chrome/browser/resources/settings/settings_main/settings_main.html @@ -2,6 +2,7 @@ <link rel="import" href="chrome://resources/cr_elements/hidden_style_css.html"> <link rel="import" href="chrome://resources/cr_elements/icons.html"> +<link rel="import" href="chrome://resources/html/search_highlight_utils.html"> <link rel="import" href="chrome://resources/html/promise_resolver.html"> <link rel="import" href="chrome://resources/polymer/v1_0/iron-a11y-announcer/iron-a11y-announcer.html"> <link rel="import" href="chrome://resources/polymer/v1_0/iron-icon/iron-icon.html"> diff --git a/chromium/chrome/browser/resources/settings/settings_page/settings_animated_pages.html b/chromium/chrome/browser/resources/settings/settings_page/settings_animated_pages.html index 5b3e82131ca..655b3faa8a0 100644 --- a/chromium/chrome/browser/resources/settings/settings_page/settings_animated_pages.html +++ b/chromium/chrome/browser/resources/settings/settings_page/settings_animated_pages.html @@ -11,6 +11,7 @@ <link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/neon-animatable.html"> <link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/neon-animated-pages.html"> <link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/neon-animation-runner-behavior.html"> +<link rel="import" href="chrome://resources/polymer/v1_0/neon-animation/web-animations.html"> <link rel="import" href="../animation/fade_animations.html"> <link rel="import" href="../route.html"> diff --git a/chromium/chrome/browser/resources/settings/settings_page/settings_section.html b/chromium/chrome/browser/resources/settings/settings_page/settings_section.html index 644879a6d93..3daf5135314 100644 --- a/chromium/chrome/browser/resources/settings/settings_page/settings_section.html +++ b/chromium/chrome/browser/resources/settings/settings_page/settings_section.html @@ -20,13 +20,13 @@ } #header .title { - @apply(--cr-section-text); + @apply --cr-section-text; margin-bottom: 0; margin-top: var(--settings-page-vertical-margin); } #card { - @apply(--shadow-elevation-2dp); + @apply --shadow-elevation-2dp; background-color: white; border-radius: 2px; flex: 1; @@ -39,7 +39,7 @@ :host(.expanding) #card, :host(.collapsing) #card, :host(.expanded) #card { - @apply(--shadow-elevation-4dp); + @apply --shadow-elevation-4dp; overflow: hidden; /* A stacking context constrains sliding sub-pages to the card. */ z-index: 0; diff --git a/chromium/chrome/browser/resources/settings/settings_page/settings_subpage.html b/chromium/chrome/browser/resources/settings/settings_page/settings_subpage.html index 12b26d259d6..1021acdcbe8 100644 --- a/chromium/chrome/browser/resources/settings/settings_page/settings_subpage.html +++ b/chromium/chrome/browser/resources/settings/settings_page/settings_subpage.html @@ -26,7 +26,7 @@ } #learnMore { - @apply(--cr-paper-icon-button-margin); + @apply --cr-paper-icon-button-margin; align-items: center; display: flex; height: var(--cr-icon-ripple-size); @@ -42,12 +42,12 @@ } paper-spinner-lite { - @apply(--cr-icon-height-width); + @apply --cr-icon-height-width; } h1 { flex: 1; /* Push other items to the end. */ - @apply(--cr-title-text); + @apply --cr-title-text; } settings-subpage-search { @@ -56,7 +56,7 @@ } </style> <div class="settings-box first" id="headerLine"> - <button is="paper-icon-button-light" on-tap="onTapBack_" + <button is="paper-icon-button-light" on-click="onTapBack_" aria-label="$i18n{back}" class="icon-arrow-back"> </button> <h1>[[pageTitle]]</h1> diff --git a/chromium/chrome/browser/resources/settings/settings_page/settings_subpage_search.html b/chromium/chrome/browser/resources/settings/settings_page/settings_subpage_search.html index 4f4245f7fec..da6272b5834 100644 --- a/chromium/chrome/browser/resources/settings/settings_page/settings_subpage_search.html +++ b/chromium/chrome/browser/resources/settings/settings_page/settings_subpage_search.html @@ -71,10 +71,10 @@ <paper-input-container no-label-float> <input id="searchInput" type="search" on-search="onSearchTermSearch" on-input="onSearchTermInput" aria-label$="[[label]]" incremental - autofocus$="[[autofocus]]" placeholder="[[label]]"> - <button suffix is="paper-icon-button-light" id="clearSearch" - class="icon-cancel" on-tap="onTapClear_" title="[[clearLabel]]" - hidden$="[[!hasSearchText]]"> + autofocus$="[[autofocus]]" placeholder="[[label]]" slot="input"> + <button is="paper-icon-button-light" id="clearSearch" + class="icon-cancel" on-click="onTapClear_" title="[[clearLabel]]" + hidden$="[[!hasSearchText]]" slot="suffix"> </button> </paper-input-container> </template> diff --git a/chromium/chrome/browser/resources/settings/settings_resources.grd b/chromium/chrome/browser/resources/settings/settings_resources.grd index 34e3036a3cc..e4122b701b2 100644 --- a/chromium/chrome/browser/resources/settings/settings_resources.grd +++ b/chromium/chrome/browser/resources/settings/settings_resources.grd @@ -325,6 +325,26 @@ file="chrome_cleanup_page/items_to_remove_list.js" type="chrome_html"/> </if> + <if expr="is_win and _google_chrome"> + <structure name="IDR_SETTINGS_INCOMPATIBLE_APPLICATIONS_PAGE_HTML" + file="incompatible_applications_page/incompatible_applications_page.html" + type="chrome_html" /> + <structure name="IDR_SETTINGS_INCOMPATIBLE_APPLICATIONS_PAGE_JS" + file="incompatible_applications_page/incompatible_applications_page.js" + type="chrome_html" /> + <structure name="IDR_SETTINGS_INCOMPATIBLE_APPLICATIONS_BROWSER_PROXY_HTML" + file="incompatible_applications_page/incompatible_applications_browser_proxy.html" + type="chrome_html" /> + <structure name="IDR_SETTINGS_INCOMPATIBLE_APPLICATIONS_BROWSER_PROXY_JS" + file="incompatible_applications_page/incompatible_applications_browser_proxy.js" + type="chrome_html" /> + <structure name="IDR_SETTINGS_INCOMPATIBLE_APPLICATIONS_INCOMPATIBLE_APPLICATION_ITEM_HTML" + file="incompatible_applications_page/incompatible_application_item.html" + type="chrome_html" /> + <structure name="IDR_SETTINGS_INCOMPATIBLE_APPLICATIONS_INCOMPATIBLE_APPLICATION_ITEM_JS" + file="incompatible_applications_page/incompatible_application_item.js" + type="chrome_html" /> + </if> <structure name="IDR_SETTINGS_CLEAR_BROWSING_DATA_BROWSER_PROXY_HTML" file="clear_browsing_data_dialog/clear_browsing_data_browser_proxy.html" type="chrome_html" /> @@ -337,12 +357,6 @@ <structure name="IDR_SETTINGS_CLEAR_BROWSING_DATA_DIALOG_JS" file="clear_browsing_data_dialog/clear_browsing_data_dialog.js" type="chrome_html" /> - <structure name="IDR_SETTINGS_CLEAR_BROWSING_DATA_DIALOG_TABS_HTML" - file="clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.html" - type="chrome_html" /> - <structure name="IDR_SETTINGS_CLEAR_BROWSING_DATA_DIALOG_TABS_JS" - file="clear_browsing_data_dialog/clear_browsing_data_dialog_tabs.js" - type="chrome_html" /> <structure name="IDR_SETTINGS_HISTORY_DELETION_DIALOG_HTML" file="clear_browsing_data_dialog/history_deletion_dialog.html" type="chrome_html" /> @@ -707,6 +721,14 @@ preprocess="true" allowexternalscript="true" /> <if expr="not chromeos"> + <structure name="IDR_SETTINGS_PEOPLE_PAGE_SYNC_ACCOUNT_CONTROL_HTML" + file="people_page/sync_account_control.html" + type="chrome_html" + flattenhtml="true" + allowexternalscript="true" /> + <structure name="IDR_SETTINGS_PEOPLE_PAGE_SYNC_ACCOUNT_CONTROL_JS" + file="people_page/sync_account_control.js" + type="chrome_html" /> <structure name="IDR_SETTINGS_PEOPLE_PAGE_IMPORT_DATA_DIALOG_HTML" file="people_page/import_data_dialog.html" type="chrome_html" /> @@ -1258,11 +1280,6 @@ type="chrome_html" preprocess="true" allowexternalscript="true" /> - <structure name="IDR_SETTINGS_PEOPLE_PIN_KEYBOARD_HTML" - file="people_page/pin_keyboard.html" - type="chrome_html" - preprocess="true" - allowexternalscript="true"/> <structure name="IDR_SETTINGS_PEOPLE_LOCK_SCREEN_JS" file="people_page/lock_screen.js" type="chrome_html" /> @@ -1319,10 +1336,6 @@ <structure name="IDR_SETTINGS_PEOPLE_FINGERPRINT_BROWSER_PROXY_HTML" file="people_page/fingerprint_browser_proxy.html" type="chrome_html" /> - <structure name="IDR_SETTINGS_KEYBOARD_PIN_JS" - file="people_page/pin_keyboard.js" - type="chrome_html" - preprocess="true" /> <structure name="IDR_SETTINGS_USERS_PAGE_ADD_USER_DIALOG_JS" file="people_page/users_add_user_dialog.js" type="chrome_html" /> diff --git a/chromium/chrome/browser/resources/settings/settings_shared_css.html b/chromium/chrome/browser/resources/settings/settings_shared_css.html index 36f43725cd7..2c89c7a5a36 100644 --- a/chromium/chrome/browser/resources/settings/settings_shared_css.html +++ b/chromium/chrome/browser/resources/settings/settings_shared_css.html @@ -2,6 +2,7 @@ <link rel="import" href="chrome://resources/cr_elements/paper_checkbox_style_css.html"> <link rel="import" href="chrome://resources/cr_elements/paper_input_style_css.html"> <link rel="import" href="chrome://resources/cr_elements/paper_toggle_style_css.html"> +<link rel="import" href="chrome://resources/cr_elements/search_highlight_style_css.html"> <link rel="import" href="chrome://resources/cr_elements/shared_vars_css.html"> <link rel="import" href="chrome://resources/cr_elements/shared_style_css.html"> <link rel="import" href="settings_icons_css.html"> @@ -11,7 +12,7 @@ <!-- Common styles for Material Design settings. --> <dom-module id="settings-shared"> <template> - <style include="settings-icons paper-button-style paper-checkbox-style paper-input-style paper-toggle-style cr-shared-style"> + <style include="settings-icons paper-button-style paper-checkbox-style paper-input-style paper-toggle-style cr-shared-style search-highlight-style"> /* Prevent action-links from being selected to avoid accidental * selection when trying to click it. */ a[is=action-link] { @@ -89,8 +90,9 @@ -webkit-margin-start: 16px; } - /* Adjust the margin between the separator and the first button. */ - .separator + paper-button { + /* Adjust the margin between the separator and the first button. Exclude + * .action-button since it has a background thus is visually different. */ + .separator + paper-button:not(.action-button) { -webkit-margin-start: calc(var(--cr-button-edge-spacing) * -1); } @@ -105,7 +107,7 @@ } paper-toggle-button { - @apply(--settings-actionable); + @apply --settings-actionable; height: var(--settings-row-min-height); user-select: none; /* Prevents text selection while dragging. */ width: 36px; @@ -154,7 +156,7 @@ /* See also: .no-min-width below. */ .text-elide { - @apply(--settings-text-elide); + @apply --cr-text-elide; } /* By default, flexbox children have min-width calculated to be the width @@ -174,7 +176,7 @@ * outside of a settings-box. A list-frame is likely to follow a * settings box. */ .list-frame { - @apply(--settings-list-frame-padding); + @apply --settings-list-frame-padding; align-items: center; display: block; } @@ -230,7 +232,7 @@ /* A settings-box is a horizontal row of text or controls within a * setting section (page or subpage). */ .settings-box { - @apply(--cr-section); + @apply --cr-section; } .settings-box.two-line { @@ -273,7 +275,7 @@ /* The lower line of text in a two-line row. */ .secondary { - @apply(--cr-secondary-text); + @apply --cr-secondary-text; } /* The |:empty| CSS selector only works when there is no whitespace. @@ -358,44 +360,6 @@ width: 16px; } - .search-bubble { - /* RGB value matches var(--paper-yellow-500). */ - --search-bubble-color: rgba(255, 235, 59, 0.9); - position: absolute; - z-index: 1; - } - - .search-bubble-innards { - align-items: center; - background-color: var(--search-bubble-color); - border-radius: 2px; - max-width: 100px; - min-width: 64px; - padding: 4px 10px; - text-align: center; - } - - /* Provides the arrow which points at the anchor element. */ - .search-bubble-innards::after { - background-color: var(--search-bubble-color); - content: ''; - height: 10px; - left: calc(50% - 5px); - position: absolute; - top: -5px; - transform: rotate(-45deg); - width: 10px; - z-index: -1; - } - - /* Turns the arrow direction downwards, when the bubble is placed above - * the anchor element */ - .search-bubble-innards.above::after { - bottom: -5px; - top: auto; - transform: rotate(-135deg); - } - .column-header { color: var(--paper-grey-600); font-weight: 500; diff --git a/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.html b/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.html index 7aca114c1af..9031db844ab 100644 --- a/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.html +++ b/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.html @@ -24,7 +24,7 @@ <template> <style include="settings-shared"> :host { - @apply(--layout-fit); + @apply --layout-fit; color: var(--primary-text-color); display: flex; flex-direction: column; @@ -38,7 +38,7 @@ } cr-toolbar { - @apply(--layout-center); + @apply --layout-center; --iron-icon-fill-color: white; background-color: var(--google-blue-700); color: white; diff --git a/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.js b/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.js index 097280993a4..4b8c62678ec 100644 --- a/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.js +++ b/chromium/chrome/browser/resources/settings/settings_ui/settings_ui.js @@ -118,6 +118,8 @@ Polymer({ loadTimeData.getString('networkListItemConnectingTo'), networkListItemInitializing: loadTimeData.getString('networkListItemInitializing'), + networkListItemScanning: + loadTimeData.getString('networkListItemScanning'), networkListItemNotConnected: loadTimeData.getString('networkListItemNotConnected'), networkListItemNoNetwork: diff --git a/chromium/chrome/browser/resources/settings/settings_vars_css.html b/chromium/chrome/browser/resources/settings/settings_vars_css.html index 595e689d955..5e9062ead38 100644 --- a/chromium/chrome/browser/resources/settings/settings_vars_css.html +++ b/chromium/chrome/browser/resources/settings/settings_vars_css.html @@ -37,12 +37,6 @@ --settings-row-three-line-min-height: var(--cr-section-three-line-min-height); - --settings-text-elide: { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - }; - --settings-separator-height: var(--cr-separator-height); --settings-separator-line: var(--cr-separator-line); diff --git a/chromium/chrome/browser/resources/settings/site_settings/add_site_dialog.html b/chromium/chrome/browser/resources/settings/site_settings/add_site_dialog.html index 94157bee5f4..3aad9d10c78 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/add_site_dialog.html +++ b/chromium/chrome/browser/resources/settings/site_settings/add_site_dialog.html @@ -35,10 +35,10 @@ </paper-checkbox> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_"> + <paper-button class="cancel-button" on-click="onCancelTap_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" id="add" on-tap="onSubmit_" + <paper-button class="action-button" id="add" on-click="onSubmit_" disabled> $i18n{add} </paper-button> diff --git a/chromium/chrome/browser/resources/settings/site_settings/all_sites.html b/chromium/chrome/browser/resources/settings/site_settings/all_sites.html index 47fa36b5184..2891cc77aa8 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/all_sites.html +++ b/chromium/chrome/browser/resources/settings/site_settings/all_sites.html @@ -18,7 +18,7 @@ <div class="list-frame menu-content vertical-list" id="listContainer"> <template is="dom-repeat" items="[[sites]]"> <div class="list-item"> - <div class="layout horizontal center flex" on-tap="onOriginTap_" + <div class="layout horizontal center flex" on-click="onOriginTap_" actionable> <div class="favicon-image" style$="[[computeSiteIcon(item.origin)]]"> diff --git a/chromium/chrome/browser/resources/settings/site_settings/category_default_setting.js b/chromium/chrome/browser/resources/settings/site_settings/category_default_setting.js index 220b76dad6f..9933ccaaabe 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/category_default_setting.js +++ b/chromium/chrome/browser/resources/settings/site_settings/category_default_setting.js @@ -97,6 +97,7 @@ Polymer({ case settings.ContentSettingsTypes.IMAGES: case settings.ContentSettingsTypes.JAVASCRIPT: case settings.ContentSettingsTypes.SOUND: + case settings.ContentSettingsTypes.SENSORS: case settings.ContentSettingsTypes.POPUPS: case settings.ContentSettingsTypes.PROTOCOL_HANDLERS: diff --git a/chromium/chrome/browser/resources/settings/site_settings/constants.js b/chromium/chrome/browser/resources/settings/site_settings/constants.js index c6a690d271f..f878c560619 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/constants.js +++ b/chromium/chrome/browser/resources/settings/site_settings/constants.js @@ -30,9 +30,10 @@ settings.ContentSettingsTypes = { MIDI_DEVICES: 'midi-sysex', USB_DEVICES: 'usb-chooser-data', ZOOM_LEVELS: 'zoom-levels', - PROTECTED_CONTENT: 'protectedContent', + PROTECTED_CONTENT: 'protected-content', ADS: 'ads', CLIPBOARD: 'clipboard', + SENSORS: 'sensors', }; /** diff --git a/chromium/chrome/browser/resources/settings/site_settings/edit_exception_dialog.html b/chromium/chrome/browser/resources/settings/site_settings/edit_exception_dialog.html index f6ab56fa94a..25da836f905 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/edit_exception_dialog.html +++ b/chromium/chrome/browser/resources/settings/site_settings/edit_exception_dialog.html @@ -19,10 +19,10 @@ </paper-input> </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCancelTap_" + <paper-button class="cancel-button" on-click="onCancelTap_" id="cancel">$i18n{cancel}</paper-button> <paper-button id="actionButton" class="action-button" - on-tap="onActionButtonTap_" disabled="[[invalid_]]"> + on-click="onActionButtonTap_" disabled="[[invalid_]]"> $i18n{edit} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.html b/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.html index a37356e6dda..b734faea7df 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.html +++ b/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.html @@ -44,34 +44,55 @@ <div class="middle" > <div class="protocol-host">[[item.host]]</div> <div class="secondary protocol-default" - hidden$="[[!isDefault_(index, protocol.default_handler)]]"> + hidden$="[[!item.is_default]]"> $i18n{handlerIsDefault} </div> </div> - <button is="paper-icon-button-light" on-tap="showMenu_" + <button is="paper-icon-button-light" on-click="showMenu_" class="icon-more-vert" title="$i18n{moreActions}"> </button> </div> - </template> </div> </template> <dialog is="cr-action-menu"> - <button class="dropdown-item" on-tap="onDefaultTap_" id="defaultButton" - hidden$="[[isModelDefault_(actionMenuModel_)]]"> + <button slot="item" class="dropdown-item" on-click="onDefaultClick_" + id="defaultButton" hidden$="[[actionMenuModel_.is_default]]"> $i18n{handlerSetDefault} </button> - <button class="dropdown-item" on-tap="onRemoveTap_" id="removeButton"> + <button slot="item" class="dropdown-item" on-click="onRemoveClick_" + id="removeButton"> $i18n{handlerRemove} </button> </dialog> + <template is="dom-if" if="[[ignoredProtocols.length]]"> + <div class="column-header">$i18n{siteSettingsBlocked}</div> + <div class="list-frame menu-content vertical-list"> + <template is="dom-repeat" items="[[ignoredProtocols]]"> + <div class="list-item"> + <div class="favicon-image" style$="[[computeSiteIcon(item.host)]]"> + </div> + <div class="middle" > + <div class="protocol-host">[[item.host]]</div> + <div class="secondary protocol-protocol">[[item.protocol]]</div> + </div> + + <button is="paper-icon-button-light" on-click="onRemoveIgnored_" + class="icon-clear" title="$i18n{moreActions}" + id="removeIgnoredButton"> + </button> + </div> + </template> + </div> + </template> + <if expr="chromeos"> <template is="dom-if" if="[[settingsAppAvailable_]]"> <div class="settings-box first" - on-tap="onManageAndroidAppsTap_" actionable> + on-click="onManageAndroidAppsClick_" actionable> <div class="start"> <div>$i18n{androidAppsManageAppLinks}</div> </div> diff --git a/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.js b/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.js index 6ad35cec77e..17667e90278 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.js +++ b/chromium/chrome/browser/resources/settings/site_settings/protocol_handlers.js @@ -19,16 +19,14 @@ const MenuActions = { /** * @typedef {{host: string, + * is_default: boolean, * protocol: string, * spec: string}} */ let HandlerEntry; /** - * @typedef {{default_handler: number, - * handlers: !Array<!HandlerEntry>, - * has_policy_recommendations: boolean, - * is_default_handler_set_by_user: boolean, + * @typedef {{handlers: !Array<!HandlerEntry>, * protocol: string}} */ let ProtocolEntry; @@ -52,7 +50,7 @@ Polymer({ /** * The targetted object for menu operations. - * @private {?Object} + * @private {?HandlerEntry} */ actionMenuModel_: Object, @@ -60,6 +58,12 @@ Polymer({ toggleOffLabel: String, toggleOnLabel: String, + /** + * Array of ignored (blocked) protocols. + * @type {!Array<!HandlerEntry>} + */ + ignoredProtocols: Array, + // <if expr="chromeos"> /** @private */ settingsAppAvailable_: { @@ -114,17 +118,6 @@ Polymer({ }, /** - * Returns whether the given index matches the default handler. - * @param {number} index The index to evaluate. - * @param {number} defaultHandler The default handler index. - * @return {boolean} Whether the item is default. - * @private - */ - isDefault_: function(index, defaultHandler) { - return defaultHandler == index; - }, - - /** * Updates the main toggle to set it enabled/disabled. * @param {boolean} enabled The state to set. * @private @@ -144,13 +137,21 @@ Polymer({ /** * Updates the list of ignored protocol handlers. - * @param {!Array<!ProtocolEntry>} args The new (ignored) protocol handler - * list. + * @param {!Array<!HandlerEntry>} ignoredProtocols The new (ignored) protocol + * handler list. * @private */ - setIgnoredProtocolHandlers_: function(args) { - // TODO(finnur): Figure this out. Have yet to be able to trigger the C++ - // side to send this. + setIgnoredProtocolHandlers_: function(ignoredProtocols) { + this.ignoredProtocols = ignoredProtocols; + }, + + /** + * Closes action menu and resets action menu model + * @private + */ + closeActionMenu_: function() { + this.$$('dialog[is=cr-action-menu]').close(); + this.actionMenuModel_ = null; }, /** @@ -165,45 +166,38 @@ Polymer({ * The handler for when "Set Default" is selected in the action menu. * @private */ - onDefaultTap_: function() { - const item = this.actionMenuModel_.item; - - this.$$('dialog[is=cr-action-menu]').close(); - this.actionMenuModel_ = null; + onDefaultClick_: function() { + const item = this.actionMenuModel_; this.browserProxy.setProtocolDefault(item.protocol, item.spec); + this.closeActionMenu_(); }, /** * The handler for when "Remove" is selected in the action menu. * @private */ - onRemoveTap_: function() { - const item = this.actionMenuModel_.item; - - this.$$('dialog[is=cr-action-menu]').close(); - this.actionMenuModel_ = null; + onRemoveClick_: function() { + const item = this.actionMenuModel_; this.browserProxy.removeProtocolHandler(item.protocol, item.spec); + this.closeActionMenu_(); }, /** - * Checks whether or not the selected actionMenuModel is the default handler - * for its protocol. - * @return {boolean} if actionMenuModel_ is default handler of its protocol. + * Handler for removing handlers that were blocked + * @private */ - isModelDefault_: function() { - return !!this.actionMenuModel_ && - (this.actionMenuModel_.index == - this.actionMenuModel_.protocol.default_handler); + onRemoveIgnored_: function(event) { + const item = event.model.item; + this.browserProxy.removeProtocolHandler(item.protocol, item.spec); }, /** * A handler to show the action menu next to the clicked menu button. - * @param {!{model: !{protocol: HandlerEntry, item: ProtocolEntry, - * index: number}}} event + * @param {!{model: !{item: HandlerEntry}}} event * @private */ showMenu_: function(event) { - this.actionMenuModel_ = event.model; + this.actionMenuModel_ = event.model.item; /** @type {!CrActionMenuElement} */ (this.$$('dialog[is=cr-action-menu]')) .showAt( /** @type {!Element} */ ( @@ -215,7 +209,7 @@ Polymer({ * Opens an activity to handle App links (preferred apps). * @private */ - onManageAndroidAppsTap_: function() { + onManageAndroidAppsClick_: function() { this.browserProxy.showAndroidManageAppLinks(); }, // </if> diff --git a/chromium/chrome/browser/resources/settings/site_settings/site_data.html b/chromium/chrome/browser/resources/settings/site_settings/site_data.html index c50d94123c3..a92719b376e 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/site_data.html +++ b/chromium/chrome/browser/resources/settings/site_settings/site_data.html @@ -25,7 +25,7 @@ } paper-spinner-lite { - @apply(--cr-icon-height-width); + @apply --cr-icon-height-width; opacity: 0; transition-delay: 1s; } @@ -46,7 +46,7 @@ <paper-spinner-lite active="[[isLoading_]]"></paper-spinner-lite> <paper-button class="secondary-button" disabled$="[[isLoading_]]" id="removeShowingSites" - on-tap="onRemoveShowingSitesTap_" hidden$="[[!sites.length]]"> + on-click="onRemoveShowingSitesTap_" hidden$="[[!sites.length]]"> [[computeRemoveLabel_(filter)]] </paper-button> </div> @@ -54,7 +54,7 @@ scroll-target="[[subpageScrollTarget]]"> <template> <div class="settings-box two-line site-item" first$="[[!index]]" - on-tap="onSiteTap_" actionable> + on-click="onSiteTap_" actionable> <div class="favicon-image" style$="background-image: [[favicon_(item.site)]]"> </div> @@ -67,7 +67,7 @@ <div class="separator"></div> <button is="paper-icon-button-light" class="icon-delete-gray" title$="[[i18n('siteSettingsCookieRemoveSite', item.site)]]" - on-tap="onRemoveSiteTap_"> + on-click="onRemoveSiteTap_"> </button> </div> </template> @@ -81,10 +81,10 @@ </div> <div slot="body">$i18n{siteSettingsCookieRemoveMultipleConfirmation}</div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCloseDialog_"> + <paper-button class="cancel-button" on-click="onCloseDialog_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onConfirmDelete_"> + <paper-button class="action-button" on-click="onConfirmDelete_"> $i18n{siteSettingsCookiesClearAll} </paper-button> </div> diff --git a/chromium/chrome/browser/resources/settings/site_settings/site_data_details_subpage.html b/chromium/chrome/browser/resources/settings/site_settings/site_data_details_subpage.html index 958e28f8cb5..aedab0ae6d3 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/site_data_details_subpage.html +++ b/chromium/chrome/browser/resources/settings/site_settings/site_data_details_subpage.html @@ -29,7 +29,7 @@ </cr-expand-button> <div class="separator"></div> <button is="paper-icon-button-light" data-id-path$="[[item.idPath]]" - class="icon-clear" on-tap="onRemove_"> + class="icon-clear" on-click="onRemove_"> </button> </div> <iron-collapse class="list-frame vertical-list" diff --git a/chromium/chrome/browser/resources/settings/site_settings/site_details.html b/chromium/chrome/browser/resources/settings/site_settings/site_details.html index 9a10a9ebf6b..b7f1908390e 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/site_details.html +++ b/chromium/chrome/browser/resources/settings/site_settings/site_details.html @@ -44,10 +44,10 @@ $i18n{siteSettingsSiteResetConfirmation} </div> <div slot="button-container"> - <paper-button class="cancel-button" on-tap="onCloseDialog_"> + <paper-button class="cancel-button" on-click="onCloseDialog_"> $i18n{cancel} </paper-button> - <paper-button class="action-button" on-tap="onClearAndReset_"> + <paper-button class="action-button" on-click="onClearAndReset_"> $i18n{siteSettingsSiteResetAll} </paper-button> </div> @@ -66,7 +66,8 @@ <div class="list-item" id="storage" hidden$="[[!storedData_]]"> <div class="start">[[storedData_]]</div> <button is="paper-icon-button-light" class="icon-delete-gray" - on-tap="onConfirmClearStorage_" alt="$i18n{siteSettingsDelete}"> + on-click="onConfirmClearStorage_" + aria-label="$i18n{siteSettingsDelete}"> </button> </div> </div> @@ -89,6 +90,12 @@ icon="settings:mic" id="mic" label="$i18n{siteSettingsMic}"> </site-details-permission> + <site-details-permission + category="{{ContentSettingsTypes.SENSORS}}" + icon="settings:sensors" id="sensors" + label="$i18n{siteSettingsSensors}" + hidden$="[[!enableSensorsContentSetting_]]"> + </site-details-permission> <site-details-permission category="{{ContentSettingsTypes.NOTIFICATIONS}}" icon="settings:notifications" id="notifications" label="$i18n{siteSettingsNotifications}"> @@ -132,12 +139,6 @@ id="midiDevices" label="$i18n{siteSettingsMidiDevices}"> </site-details-permission> <site-details-permission - category="{{ContentSettingsTypes.CLIPBOARD}}" - icon="settings:clipboard" id="clipboard" - label="$i18n{siteSettingsClipboard}" - hidden$="[[!enableClipboardContentSetting_]]"> - </site-details-permission> - <site-details-permission category="{{ContentSettingsTypes.UNSANDBOXED_PLUGINS}}" icon="cr:extension" id="unsandboxedPlugins" label="$i18n{siteSettingsUnsandboxedPlugins}"> @@ -149,10 +150,16 @@ label="$i18n{siteSettingsProtectedContentIdentifiers}"> </site-details-permission> </if> + <site-details-permission + category="{{ContentSettingsTypes.CLIPBOARD}}" + icon="settings:clipboard" id="clipboard" + label="$i18n{siteSettingsClipboard}" + hidden$="[[!enableClipboardContentSetting_]]"> + </site-details-permission> </div> <div id="clearAndReset" class="settings-box" - on-tap="onConfirmClearSettings_" actionable> + on-click="onConfirmClearSettings_" actionable> <div class="start"> $i18n{siteSettingsReset} </div> diff --git a/chromium/chrome/browser/resources/settings/site_settings/site_details.js b/chromium/chrome/browser/resources/settings/site_settings/site_details.js index 6d4f9c7c7db..73bd087ea10 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/site_details.js +++ b/chromium/chrome/browser/resources/settings/site_settings/site_details.js @@ -74,6 +74,15 @@ Polymer({ }, }, + /** @private */ + enableSensorsContentSetting_: { + type: Boolean, + readOnly: true, + value: function() { + return loadTimeData.getBoolean('enableSensorsContentSetting'); + }, + }, + /** * The type of storage for the origin. * @private @@ -236,6 +245,8 @@ Polymer({ onClearAndReset_: function() { this.browserProxy.setOriginPermissions( this.origin, this.getCategoryList_(), settings.ContentSetting.DEFAULT); + if (this.getCategoryList_().includes(settings.ContentSettingsTypes.PLUGINS)) + this.browserProxy.clearFlashPref(this.origin); if (this.storedData_ != '') this.onClearStorage_(); diff --git a/chromium/chrome/browser/resources/settings/site_settings/site_list.html b/chromium/chrome/browser/resources/settings/site_settings/site_list.html index 4f0141e4d32..9090d7df259 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/site_list.html +++ b/chromium/chrome/browser/resources/settings/site_settings/site_list.html @@ -30,29 +30,31 @@ <h2 class="start">[[categoryHeader]]</h2> <paper-button id="addSite" class="secondary-button header-aligned-button" - hidden="[[readOnlyList]]" on-tap="onAddSiteTap_"> + hidden="[[readOnlyList]]" on-click="onAddSiteTap_"> $i18n{add} </paper-button> </div> <dialog is="cr-action-menu"> - <button class="dropdown-item" id="allow" - on-tap="onAllowTap_" hidden$="[[!showAllowAction_]]"> + <button slot="item" class="dropdown-item" id="allow" + on-click="onAllowTap_" hidden$="[[!showAllowAction_]]"> $i18n{siteSettingsActionAllow} </button> - <button class="dropdown-item" id="block" - on-tap="onBlockTap_" hidden$="[[!showBlockAction_]]"> + <button slot="item" class="dropdown-item" id="block" + on-click="onBlockTap_" hidden$="[[!showBlockAction_]]"> $i18n{siteSettingsActionBlock} </button> - <button class="dropdown-item" id="sessionOnly" - on-tap="onSessionOnlyTap_" + <button slot="item" class="dropdown-item" id="sessionOnly" + on-click="onSessionOnlyTap_" hidden$="[[!showSessionOnlyActionForSite_(actionMenuSite_)]]"> $i18n{siteSettingsActionSessionOnly} </button> - <button class="dropdown-item" id="edit" on-tap="onEditTap_"> + <button slot="item" class="dropdown-item" id="edit" + on-click="onEditTap_"> $i18n{edit} </button> - <button class="dropdown-item" id="reset" on-tap="onResetTap_"> + <button slot="item" class="dropdown-item" id="reset" + on-click="onResetTap_"> $i18n{siteSettingsActionReset} </button> </dialog> @@ -64,7 +66,7 @@ <template is="dom-repeat" items="[[sites]]"> <div class="list-item"> <div class="settings-row" - actionable$="[[enableSiteSettings_]]" on-tap="onOriginTap_"> + actionable$="[[enableSiteSettings_]]" on-click="onOriginTap_"> <div class="favicon-image" style$="[[computeSiteIcon(item.origin)]]"> </div> @@ -76,7 +78,7 @@ id="siteDescription">[[computeSiteDescription_(item)]]</div> </div> <template is="dom-if" if="[[enableSiteSettings_]]"> - <div on-tap="onOriginTap_" actionable> + <div on-click="onOriginTap_" actionable> <button class="subpage-arrow" is="paper-icon-button-light" aria-label$="[[item.displayName]]" aria-describedby="siteDescription"></button> @@ -90,12 +92,12 @@ </cr-policy-pref-indicator> </template> <button is="paper-icon-button-light" id="resetSite" - class="icon-delete-gray" on-tap="onResetButtonTap_" + class="icon-delete-gray" on-click="onResetButtonTap_" hidden="[[shouldHideResetButton_(item, readOnlyList)]]" - alt="$i18n{siteSettingsActionReset}"> + aria-label="$i18n{siteSettingsActionReset}"> </button> <button is="paper-icon-button-light" id="actionMenuButton" - class="icon-more-vert" on-tap="onShowActionMenuTap_" + class="icon-more-vert" on-click="onShowActionMenuTap_" hidden="[[shouldHideActionMenu_(item, readOnlyList)]]" title="$i18n{moreActions}"> </button> diff --git a/chromium/chrome/browser/resources/settings/site_settings/site_settings_prefs_browser_proxy.js b/chromium/chrome/browser/resources/settings/site_settings/site_settings_prefs_browser_proxy.js index 81a019ec3c4..7974cfdf2a1 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/site_settings_prefs_browser_proxy.js +++ b/chromium/chrome/browser/resources/settings/site_settings/site_settings_prefs_browser_proxy.js @@ -137,6 +137,13 @@ cr.define('settings', function() { setOriginPermissions(origin, contentTypes, blanketSetting) {} /** + * Clears the flag that's set when the user has changed the Flash permission + * for this particular origin. + * @param {string} origin The origin to clear the Flash preference for. + */ + clearFlashPref(origin) {} + + /** * Resets the category permission for a given origin (expressed as primary * and secondary patterns). Only use this if intending to remove an * exception - use setOriginPermissions() for origin-scoped settings. @@ -307,6 +314,11 @@ cr.define('settings', function() { } /** @override */ + clearFlashPref(origin) { + chrome.send('clearFlashPref', [origin]); + } + + /** @override */ resetCategoryPermissionForPattern( primaryPattern, secondaryPattern, contentType, incognito) { chrome.send( @@ -362,12 +374,12 @@ cr.define('settings', function() { /** @override */ setProtocolDefault(protocol, url) { - chrome.send('setDefault', [[protocol, url]]); + chrome.send('setDefault', [protocol, url]); } /** @override */ removeProtocolHandler(protocol, url) { - chrome.send('removeHandler', [[protocol, url]]); + chrome.send('removeHandler', [protocol, url]); } /** @override */ diff --git a/chromium/chrome/browser/resources/settings/site_settings/usb_devices.html b/chromium/chrome/browser/resources/settings/site_settings/usb_devices.html index 0d49492c635..9b4e5e046d2 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/usb_devices.html +++ b/chromium/chrome/browser/resources/settings/site_settings/usb_devices.html @@ -33,7 +33,7 @@ style$="[[computeSiteIcon(item.origin)]]"></div> <div class="middle">[[item.origin]]</div> - <button is="paper-icon-button-light" on-tap="showMenu_" + <button is="paper-icon-button-light" on-click="showMenu_" class="icon-more-vert" title="$i18n{moreActions}"> </button> </div> @@ -41,7 +41,8 @@ </template> <dialog is="cr-action-menu"> - <button id="removeButton" class="dropdown-item" on-tap="onRemoveTap_"> + <button id="removeButton" slot="item" class="dropdown-item" + on-click="onRemoveTap_"> $i18n{handlerRemove} </button> </dialog> diff --git a/chromium/chrome/browser/resources/settings/site_settings/zoom_levels.html b/chromium/chrome/browser/resources/settings/site_settings/zoom_levels.html index e651cd9ad88..facdc237e3e 100644 --- a/chromium/chrome/browser/resources/settings/site_settings/zoom_levels.html +++ b/chromium/chrome/browser/resources/settings/site_settings/zoom_levels.html @@ -36,7 +36,7 @@ <div class="zoom-label">[[item.zoom]]</div> <div> <button is="paper-icon-button-light" class="icon-clear" - on-tap="removeZoomLevel_" + on-click="removeZoomLevel_" title="$i18n{siteSettingsRemoveZoomLevel}"></button> </div> </div> diff --git a/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.html b/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.html index c523b2f8536..37210d136c7 100644 --- a/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.html +++ b/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.html @@ -18,7 +18,7 @@ </style> <template is="dom-if" if="[[enableSiteSettings_]]"> <div class="settings-box first" category$="[[ALL_SITES]]" - data-route="SITE_SETTINGS_ALL" on-tap="onTapNavigate_" actionable> + data-route="SITE_SETTINGS_ALL" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:list"></iron-icon> <div class="middle">$i18n{siteSettingsCategoryAllSites}</div> <button class="subpage-arrow" is="paper-icon-button-light" @@ -29,7 +29,7 @@ </template> <div id="cookies" class="settings-box two-line first" category$="[[ContentSettingsTypes.COOKIES]]" - data-route="SITE_SETTINGS_COOKIES" on-tap="onTapNavigate_" actionable> + data-route="SITE_SETTINGS_COOKIES" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:cookie"></iron-icon> <div class="middle"> $i18n{siteSettingsCookies} @@ -47,7 +47,8 @@ </div> <div id="location" class="settings-box two-line" category$="[[ContentSettingsTypes.GEOLOCATION]]" - data-route="SITE_SETTINGS_LOCATION" on-tap="onTapNavigate_" actionable> + data-route="SITE_SETTINGS_LOCATION" on-click="onTapNavigate_" + actionable> <iron-icon icon="settings:location-on"></iron-icon> <div class="middle"> $i18n{siteSettingsLocation} @@ -65,7 +66,7 @@ <div id="camera" class="settings-box two-line" category$="[[ContentSettingsTypes.CAMERA]]" data-route="SITE_SETTINGS_CAMERA" - on-tap="onTapNavigate_" actionable> + on-click="onTapNavigate_" actionable> <iron-icon icon="settings:videocam"></iron-icon> <div class="middle"> $i18n{siteSettingsCamera} @@ -82,7 +83,7 @@ </div> <div id="microphone" class="settings-box two-line" category$="[[ContentSettingsTypes.MIC]]" - data-route="SITE_SETTINGS_MICROPHONE" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_MICROPHONE" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:mic"></iron-icon> <div class="middle"> @@ -98,9 +99,29 @@ aria-label="$i18n{siteSettingsMic}" aria-describedby="micSecondary"></button> </div> + <template is="dom-if" if="[[enableSensorsContentSetting_]]"> + <div id="sensors" class="settings-box two-line" + category$="[[ContentSettingsTypes.SENSORS]]" + data-route="SITE_SETTINGS_SENSORS" on-click="onTapNavigate_" + actionable> + <iron-icon icon="settings:sensors"></iron-icon> + <div class="middle"> + $i18n{siteSettingsSensors} + <div class="secondary" id="sensorsSecondary"> + [[defaultSettingLabel_( + default_.sensors, + '$i18nPolymer{siteSettingsSensorsAllow}', + '$i18nPolymer{siteSettingsSensorsBlock}')]] + </div> + </div> + <button class="subpage-arrow" is="paper-icon-button-light" + aria-label="$i18n{siteSettingsSensors}" + aria-describedby="sensorsSecondary"></button> + </div> + </template> <div id="notifications" class="settings-box two-line" category$="[[ContentSettingsTypes.NOTIFICATIONS]]" - data-route="SITE_SETTINGS_NOTIFICATIONS" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_NOTIFICATIONS" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:notifications"></iron-icon> <div class="middle"> @@ -118,7 +139,7 @@ </div> <div id="javascript" class="settings-box two-line" category$="[[ContentSettingsTypes.JAVASCRIPT]]" - data-route="SITE_SETTINGS_JAVASCRIPT" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_JAVASCRIPT" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:code"></iron-icon> <div class="middle"> @@ -136,7 +157,7 @@ </div> <div id="flash" class="settings-box two-line" category$="[[ContentSettingsTypes.PLUGINS]]" - data-route="SITE_SETTINGS_FLASH" on-tap="onTapNavigate_" actionable> + data-route="SITE_SETTINGS_FLASH" on-click="onTapNavigate_" actionable> <iron-icon icon="cr:extension"></iron-icon> <div class="middle"> $i18n{siteSettingsFlash} @@ -153,7 +174,7 @@ </div> <div id="images" class="settings-box two-line" category$="[[ContentSettingsTypes.IMAGES]]" - data-route="SITE_SETTINGS_IMAGES" on-tap="onTapNavigate_" actionable> + data-route="SITE_SETTINGS_IMAGES" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:photo"></iron-icon> <div class="middle"> $i18n{siteSettingsImages} @@ -170,7 +191,7 @@ </div> <div id="popups" category$="[[ContentSettingsTypes.POPUPS]]" class="settings-box two-line" data-route="SITE_SETTINGS_POPUPS" - on-tap="onTapNavigate_" actionable> + on-click="onTapNavigate_" actionable> <iron-icon icon="cr:open-in-new"></iron-icon> <div class="middle"> $i18n{siteSettingsPopups} @@ -188,7 +209,7 @@ <template is="dom-if" if="[[enableSafeBrowsingSubresourceFilter_]]"> <div id="ads" class="settings-box two-line" category$="[[ContentSettingsTypes.ADS]]" - data-route="SITE_SETTINGS_ADS" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_ADS" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:ads"></iron-icon> <div class="middle"> @@ -207,7 +228,7 @@ </template> <div id="background-sync" class="settings-box two-line" category$="[[ContentSettingsTypes.BACKGROUND_SYNC]]" - data-route="SITE_SETTINGS_BACKGROUND_SYNC" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_BACKGROUND_SYNC" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:sync"></iron-icon> <div class="middle"> @@ -226,7 +247,7 @@ <template is="dom-if" if="[[enableSoundContentSetting_]]"> <div id="sound" class="settings-box two-line" category$="[[ContentSettingsTypes.SOUND]]" - data-route="SITE_SETTINGS_SOUND" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_SOUND" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:volume-up"></iron-icon> <div class="middle"> @@ -246,7 +267,7 @@ <div id="automatic-downloads" class="settings-box two-line" category$="[[ContentSettingsTypes.AUTOMATIC_DOWNLOADS]]" data-route="SITE_SETTINGS_AUTOMATIC_DOWNLOADS" - on-tap="onTapNavigate_" actionable> + on-click="onTapNavigate_" actionable> <iron-icon icon="cr:file-download"></iron-icon> <div class="middle"> $i18n{siteSettingsAutomaticDownloads} @@ -264,7 +285,7 @@ <div id="unsandboxed-plugins" class="settings-box two-line" category$="[[ContentSettingsTypes.UNSANDBOXED_PLUGINS]]" data-route="SITE_SETTINGS_UNSANDBOXED_PLUGINS" - on-tap="onTapNavigate_" actionable> + on-click="onTapNavigate_" actionable> <iron-icon icon="cr:extension"></iron-icon> <div class="middle"> $i18n{siteSettingsUnsandboxedPlugins} @@ -283,7 +304,7 @@ <div id="protocol-handlers" class="settings-box two-line" category$="[[ContentSettingsTypes.PROTOCOL_HANDLERS]]" data-route="SITE_SETTINGS_HANDLERS" - on-tap="onTapNavigate_" actionable> + on-click="onTapNavigate_" actionable> <iron-icon icon="settings:protocol-handler"></iron-icon> <div class="middle"> $i18n{siteSettingsHandlers} @@ -302,7 +323,7 @@ <div id="midi-devices" class="settings-box two-line" category$="[[ContentSettingsTypes.MIDI_DEVICES]]" data-route="SITE_SETTINGS_MIDI_DEVICES" - on-tap="onTapNavigate_" actionable> + on-click="onTapNavigate_" actionable> <iron-icon icon="settings:midi"></iron-icon> <div class="middle"> $i18n{siteSettingsMidiDevices} @@ -317,29 +338,9 @@ aria-label="$i18n{siteSettingsMidiDevices}" aria-describedby="midiDevicesSecondary"></button> </div> - <template is="dom-if" if="[[enableClipboardContentSetting_]]"> - <div id="clipboard" class="settings-box two-line" - category$="[[ContentSettingsTypes.CLIPBOARD]]" - data-route="SITE_SETTINGS_CLIPBOARD" on-tap="onTapNavigate_" - actionable> - <iron-icon icon="settings:clipboard"></iron-icon> - <div class="middle"> - $i18n{siteSettingsClipboard} - <div class="secondary" id="clipboardSecondary"> - [[defaultSettingLabel_( - default_.clipboard, - '$i18nPolymer{siteSettingsAskBeforeAccessing}', - '$i18nPolymer{siteSettingsBlocked}')]] - </div> - </div> - <button class="subpage-arrow" is="paper-icon-button-light" - aria-label="$i18n{siteSettingsClipboard}" - aria-describedby="clipboardSecondary"></button> - </div> - </template> <div id="zoom-levels" class="settings-box" category$="[[ContentSettingsTypes.ZOOM_LEVELS]]" - data-route="SITE_SETTINGS_ZOOM_LEVELS" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_ZOOM_LEVELS" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:zoom-in"></iron-icon> <div class="middle">$i18n{siteSettingsZoomLevels}</div> @@ -348,7 +349,7 @@ </div> <div id="usb-devices" class="settings-box" category$="[[ContentSettingsTypes.USB_DEVICES]]" - data-route="SITE_SETTINGS_USB_DEVICES" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_USB_DEVICES" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:usb"></iron-icon> <div class="middle">$i18n{siteSettingsUsbDevices}</div> @@ -356,7 +357,7 @@ aria-label="$i18n{siteSettingsUsbDevices}"></button> </div> <div id="pdf-documents" class="settings-box" - data-route="SITE_SETTINGS_PDF_DOCUMENTS" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_PDF_DOCUMENTS" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:pdf"></iron-icon> <div class="middle">$i18n{siteSettingsPdfDocuments}</div> @@ -364,13 +365,33 @@ aria-label="$i18n{siteSettingsPdfDocuments}"></button> </div> <div id="protected-content" class="settings-box" - data-route="SITE_SETTINGS_PROTECTED_CONTENT" on-tap="onTapNavigate_" + data-route="SITE_SETTINGS_PROTECTED_CONTENT" on-click="onTapNavigate_" actionable> <iron-icon icon="settings:security"></iron-icon> <div class="middle">$i18n{siteSettingsProtectedContent}</div> <button class="subpage-arrow" is="paper-icon-button-light" aria-label="$i18n{siteSettingsProtectedContent}"></button> </div> + <template is="dom-if" if="[[enableClipboardContentSetting_]]"> + <div id="clipboard" class="settings-box two-line" + category$="[[ContentSettingsTypes.CLIPBOARD]]" + data-route="SITE_SETTINGS_CLIPBOARD" on-click="onTapNavigate_" + actionable> + <iron-icon icon="settings:clipboard"></iron-icon> + <div class="middle"> + $i18n{siteSettingsClipboard} + <div class="secondary" id="clipboardSecondary"> + [[defaultSettingLabel_( + default_.clipboard, + '$i18nPolymer{siteSettingsAskBeforeAccessing}', + '$i18nPolymer{siteSettingsBlocked}')]] + </div> + </div> + <button class="subpage-arrow" is="paper-icon-button-light" + aria-label="$i18n{siteSettingsClipboard}" + aria-describedby="clipboardSecondary"></button> + </div> + </template> </template> <script src="site_settings_page.js"></script> </dom-module> diff --git a/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.js b/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.js index 0b1ce9dc1de..5a3b98d12af 100644 --- a/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.js +++ b/chromium/chrome/browser/resources/settings/site_settings_page/site_settings_page.js @@ -67,6 +67,15 @@ Polymer({ } }, + /** @private */ + enableSensorsContentSetting_: { + type: Boolean, + readOnly: true, + value: function() { + return loadTimeData.getBoolean('enableSensorsContentSetting'); + } + }, + /** @type {!Map<string, string>} */ focusConfig: { type: Object, @@ -105,6 +114,7 @@ Polymer({ [R.SITE_SETTINGS_PDF_DOCUMENTS, 'pdf-documents'], [R.SITE_SETTINGS_PROTECTED_CONTENT, 'protected-content'], [R.SITE_SETTINGS_CLIPBOARD, 'clipboard'], + [R.SITE_SETTINGS_SENSORS, 'sensors'], ].forEach(pair => { const route = pair[0]; const id = pair[1]; diff --git a/chromium/chrome/browser/resources/settings/system_page/system_page.html b/chromium/chrome/browser/resources/settings/system_page/system_page.html index 77bc47816f3..fb4ed5031ee 100644 --- a/chromium/chrome/browser/resources/settings/system_page/system_page.html +++ b/chromium/chrome/browser/resources/settings/system_page/system_page.html @@ -29,7 +29,7 @@ </paper-button> </template> </settings-toggle-button> - <div id="proxy" class="settings-box" on-tap="onProxyTap_" + <div id="proxy" class="settings-box" on-click="onProxyTap_" actionable$="[[!isProxyEnforcedByPolicy_]]"> <div class="start">$i18n{proxySettingsLabel}</div> <button is="paper-icon-button-light" class="icon-external" diff --git a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google.png b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google.png Binary files differindex 3c38ca006b5..f7602a57b40 100644 --- a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google.png +++ b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google.png diff --git a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google_2x.png b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google_2x.png Binary files differindex 3634aa48a93..76d098584c9 100644 --- a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google_2x.png +++ b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/images/ic_google_2x.png diff --git a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.html b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.html index 41dac3c5e3b..644c00a0c8f 100644 --- a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.html +++ b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.html @@ -81,9 +81,9 @@ } #personalize-logo { - fill: var(--google-blue-700); /* Need the following rules to adjust for white spacing in the svg. */ -webkit-margin-end: 14px; + fill: var(--google-blue-700); height: 18px; width: 18px; } @@ -107,37 +107,52 @@ url(./images/ic_google_2x.png) 2x); } </style> + + <!-- + Use the 'consent-description' attribute to annotate all the UI elements + that are part of the text the user reads before consenting to the Sync + data collection . Similarly, use 'consent-confirmation' on UI elements on + which user clicks to indicate consent. + --> + <div id="illustration-container"></div> - <h1 id="heading">$i18n{syncConfirmationTitle}</h1> + <h1 id="heading" consent-description>$i18n{syncConfirmationTitle}</h1> <div class="message-container"> <!-- Container needed to contain the icon in a green circle. --> <div id="sync-logo-container" class="logo"> <iron-icon icon="notification:sync" class="logo"> </iron-icon> </div> - <div>$i18n{syncConfirmationChromeSyncBody}</div> + <div consent-description>$i18n{syncConfirmationChromeSyncBody}</div> </div> <div class="message-container"> <iron-icon icon="image:assistant" id="personalize-logo" class="logo"> </iron-icon> - <div>$i18n{syncConfirmationPersonalizeServicesBody}</div> + <div consent-description> + $i18n{syncConfirmationPersonalizeServicesBody} + </div> </div> <div class="message-container"> <div id="googleg-logo" class="logo"></div> - <div>$i18n{syncConfirmationGoogleServicesBody}</div> + <div consent-description>$i18n{syncConfirmationGoogleServicesBody}</div> </div> <div class="footer"> <div class="message-container"> <iron-icon icon="icons:settings" class="logo"></iron-icon> - <div>$i18nRaw{syncConfirmationSyncSettingsLinkBody}</div> + <div consent-description consent-confirmation> + $i18nRaw{syncConfirmationSyncSettingsLinkBody} + </div> </div> <div class="message-container"> <div class="logo"><!-- Spacer to line up with other texts --></div> - <div>$i18n{syncConfirmationSyncSettingsDescription}</div> + <div consent-description> + $i18n{syncConfirmationSyncSettingsDescription} + </div> </div> </div> <div class="action-container"> - <paper-button class="primary-action" id="confirmButton" on-tap="onConfirm_"> + <paper-button class="primary-action" id="confirmButton" + on-tap="onConfirm_" consent-confirmation> $i18n{syncConfirmationConfirmLabel} </paper-button> <paper-button class="secondary-action" id="undoButton" on-tap="onUndo_"> diff --git a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.js b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.js index bb45993114c..fe19b8f3dd4 100644 --- a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.js +++ b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_app.js @@ -33,8 +33,9 @@ Polymer({ }, /** @private */ - onConfirm_: function() { - this.syncConfirmationBrowserProxy_.confirm(); + onConfirm_: function(e) { + this.syncConfirmationBrowserProxy_.confirm( + this.getConsentDescription_(), this.getConsentConfirmation_(e.path)); }, /** @private */ @@ -43,15 +44,41 @@ Polymer({ }, /** @private */ - onGoToSettings_: function() { - this.syncConfirmationBrowserProxy_.goToSettings(); + onGoToSettings_: function(e) { + this.syncConfirmationBrowserProxy_.goToSettings( + this.getConsentDescription_(), this.getConsentConfirmation_(e.path)); }, /** @private */ onKeyDown_: function(e) { if (e.key == 'Enter' && !/^(A|PAPER-BUTTON)$/.test(e.path[0].tagName)) { - this.onConfirm_(); + this.onConfirm_(e); e.preventDefault(); } }, + + /** + * @param {!Array<!HTMLElement>} path Path of the click event. Must contain + * a consent confirmation element. + * @return {string} The text of the consent confirmation element. + * @private + */ + getConsentConfirmation_: function(path) { + for (var element of path) { + if (element.hasAttribute('consent-confirmation')) + return element.innerHTML.trim(); + } + assertNotReached('No consent confirmation element found.'); + return ''; + }, + + /** @return {!Array<string>} Text of the consent description elements. */ + getConsentDescription_: function() { + var consentDescription = + Array.from(this.shadowRoot.querySelectorAll('[consent-description]')) + .filter(element => element.clientWidth * element.clientHeight > 0) + .map(element => element.innerHTML.trim()); + assert(consentDescription); + return consentDescription; + } }); diff --git a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_browser_proxy.js b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_browser_proxy.js index 304c3024888..f6d3b1096be 100644 --- a/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_browser_proxy.js +++ b/chromium/chrome/browser/resources/signin/dice_sync_confirmation/sync_confirmation_browser_proxy.js @@ -11,9 +11,27 @@ cr.define('sync.confirmation', function() { /** @interface */ class SyncConfirmationBrowserProxy { - confirm() {} + /** + * Called when the user confirms the Sync Confirmation dialog. + * @param {!Array<string>} description Strings that the user was presented + * with in the UI. + * @param {string} confirmation Text of the element that the user + * clicked on. + */ + confirm(description, confirmation) {} + + /** Called when the user undoes the Sync confirmation. */ undo() {} - goToSettings() {} + + /** + * Called when the user clicks on the Settings link in + * the Sync Confirmation dialog. + * @param {!Array<string>} description Strings that the user was presented + * with in the UI. + * @param {string} confirmation Text of the element that the user + * clicked on. + */ + goToSettings(description, confirmation) {} /** @param {!Array<number>} height */ initializedWithSize(height) {} @@ -22,8 +40,8 @@ cr.define('sync.confirmation', function() { /** @implements {sync.confirmation.SyncConfirmationBrowserProxy} */ class SyncConfirmationBrowserProxyImpl { /** @override */ - confirm() { - chrome.send('confirm'); + confirm(description, confirmation) { + chrome.send('confirm', [description, confirmation]); } /** @override */ @@ -32,8 +50,8 @@ cr.define('sync.confirmation', function() { } /** @override */ - goToSettings() { - chrome.send('goToSettings'); + goToSettings(description, confirmation) { + chrome.send('goToSettings', [description, confirmation]); } /** @override */ diff --git a/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.html b/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.html index 99a1e1122d7..74acb08a2dd 100644 --- a/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.html +++ b/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.html @@ -18,8 +18,16 @@ </style> </head> <body> + <!-- + Use the 'consent-description' attribute to annotate all the UI elements + that are part of the text the user reads before consenting to the Sync + data collection . Similarly, use 'consent-confirmation' on UI elements on + which user clicks to indicate consent. + --> <div class="container"> - <div class="top-title-bar">$i18n{syncConfirmationTitle}</div> + <div class="top-title-bar" consent-description> + $i18n{syncConfirmationTitle} + </div> <div class="details" id="syncConfirmationDetails"> <div id="picture-container"> <div id="illustration"> @@ -55,8 +63,12 @@ --> <div id="chrome-logo" class="logo"></div> <div> - <div class="title">$i18n{syncConfirmationChromeSyncTitle}</div> - <div class="body text">$i18n{syncConfirmationChromeSyncBody}</div> + <div class="title" consent-description> + $i18n{syncConfirmationChromeSyncTitle} + </div> + <div class="body text" consent-description> + $i18n{syncConfirmationChromeSyncBody} + </div> </div> </div> <div class="message-container"> @@ -66,23 +78,28 @@ --> <div id="googleg-logo" class="logo"></div> <div> - <div class="title"> + <div class="title" consent-description> $i18n{syncConfirmationPersonalizeServicesTitle} </div> - <div class="body text"> + <div class="body text" consent-description> $i18n{syncConfirmationPersonalizeServicesBody} </div> </div> </div> <div class="message-container"> - <div class="body">$i18nRaw{syncConfirmationSyncSettingsLinkBody}</div> + <div class="body" consent-description consent-confirmation> + $i18nRaw{syncConfirmationSyncSettingsLinkBody} + </div> </div> </div> <div class="details" id="syncDisabledDetails"> - <div class="body text">$i18n{syncDisabledConfirmationDetails}</div> + <div class="body text" consent-description> + $i18n{syncDisabledConfirmationDetails} + </div> </div> <div class="action-container"> - <paper-button class="primary-action" id="confirmButton"> + <paper-button class="primary-action" id="confirmButton" + consent-confirmation> $i18n{syncConfirmationConfirmLabel} </paper-button> <paper-button class="secondary-action" id="undoButton"> diff --git a/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.js b/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.js index 17eb3bfa71c..72d29205331 100644 --- a/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.js +++ b/chromium/chrome/browser/resources/signin/sync_confirmation/sync_confirmation.js @@ -5,8 +5,35 @@ cr.define('sync.confirmation', function() { 'use strict'; + /** + * @param {!Array<!HTMLElement>} path Path of the click event. Must contain + * a consent confirmation element. + * @return {string} The text of the consent confirmation element. + * @private + */ + function getConsentConfirmation(path) { + var consentConfirmation; + for (var element of path) { + if (element.hasAttribute('consent-confirmation')) + return element.innerHTML.trim(); + } + assertNotReached('No consent confirmation element found.'); + return ''; + } + + /** @return {!Array<string>} Text of the consent description elements. */ + function getConsentDescription() { + var consentDescription = + Array.from(document.querySelectorAll('[consent-description]')) + .filter(element => element.clientWidth * element.clientHeight > 0) + .map(element => element.innerHTML.trim()); + assert(consentDescription); + return consentDescription; + } + function onConfirm(e) { - chrome.send('confirm'); + chrome.send( + 'confirm', [getConsentDescription(), getConsentConfirmation(e.path)]); } function onUndo(e) { @@ -14,7 +41,9 @@ cr.define('sync.confirmation', function() { } function onGoToSettings(e) { - chrome.send('goToSettings'); + chrome.send( + 'goToSettings', + [getConsentDescription(), getConsentConfirmation(e.path)]); } function initialize() { diff --git a/chromium/chrome/browser/resources/snippets_internals.html b/chromium/chrome/browser/resources/snippets_internals.html index 27ee59e3a95..6b3cb663e44 100644 --- a/chromium/chrome/browser/resources/snippets_internals.html +++ b/chromium/chrome/browser/resources/snippets_internals.html @@ -90,11 +90,10 @@ found in the LICENSE file. </div> <div id="snippets"> - <h2>NTPSnippetsService</h2> + <h2>ContentSuggestionsService</h2> <div class="forms"> <div> - <button id="submit-download" type="button">Add snippets</button> - <span id="remote-status" class="detail"></span> + <button id="submit-download" type="button">Reload suggestions</button> </div> <div> <button id="debug-log-dump" type="button">Dump the debug log</button> @@ -107,27 +106,36 @@ found in the LICENSE file. </div> </div> - <div id="last-json" class="hidden"> - <h2>Last JSON from Server</h2> - <a id="last-json-button">Show the last JSON >></a> - <div id="last-json-container" class="hidden"> - <div id="last-json-text"></div> - <button id="last-json-dump" type="button">Dump the last JSON</button> - </div> - </div> - <div id="remote-content-suggestions"> <h2>Remote content suggestions</h2> + <table class="section-details"> + <tr> + <td class="name">Last Fetch Status + <td id="remote-status" class="value"> + <tr> + <td class="name">Last Fetch Type + <td id="remote-authenticated" class="value"> + <tr> + <td class="name">Last Background Fetch Time: + <td id="last-background-fetch-time-label" class="value"> + </table> <div> - <span>Last Background Fetch Time: </span> - <span id="last-background-fetch-time-label"></span> + <button id="background-fetch-button" type="button"> + Fetch remote suggestions in the background in 2 seconds + </button> + </div> + <div> + <button id="push-dummy-suggestion-10-seconds-button" type="button"> + Push dummy suggestion in 10 seconds + </button> + </div> + <div> + <button id="last-json-button" type="button">Show the last JSON</button> + </div> + <div id="last-json-container" class="hidden"> + <div id="last-json-text"></div> + <button id="last-json-dump" type="button">Dump the last JSON</button> </div> - <button id="background-fetch-button" type="button"> - Fetch remote suggestions in the background in 2 seconds - </button> - <button id="push-dummy-suggestion-10-seconds-button" type="button"> - Push dummy suggestion in 10 seconds - </button> </div> <div id="notifications"> diff --git a/chromium/chrome/browser/resources/vr/assets/PRESUBMIT.py b/chromium/chrome/browser/resources/vr/assets/PRESUBMIT.py index 5d740312570..c290f6d7b3d 100644 --- a/chromium/chrome/browser/resources/vr/assets/PRESUBMIT.py +++ b/chromium/chrome/browser/resources/vr/assets/PRESUBMIT.py @@ -12,10 +12,11 @@ def IsNewer(old_version, new_version): old_version.minor < new_version.minor))) -def CheckVersion(input_api, output_api): +def CheckVersionAndAssetParity(input_api, output_api): """Checks that - the version was upraded if assets files were changed, - - the version was not downgraded. + - the version was not downgraded, + - both the google_chrome and the chromium assets have the same files. """ sys.path.append(input_api.PresubmitLocalPath()) import parse_version @@ -24,9 +25,21 @@ def CheckVersion(input_api, output_api): new_version = None changed_assets = False changed_version = False + changed_asset_files = {'google_chrome': [], 'chromium': []} for file in input_api.AffectedFiles(): basename = input_api.os_path.basename(file.LocalPath()) extension = input_api.os_path.splitext(basename)[1][1:].strip().lower() + basename_without_extension = input_api.os_path.splitext(basename)[ + 0].strip().lower() + if extension == 'sha1': + basename_without_extension = input_api.os_path.splitext( + basename_without_extension)[0] + dirname = input_api.os_path.basename( + input_api.os_path.dirname(file.LocalPath())) + action = file.Action() + if (dirname in changed_asset_files and extension in {'sha1', 'png'} and + action in {'A', 'D'}): + changed_asset_files[dirname].append((action, basename_without_extension)) if (extension == 'sha1' or basename == 'vr_assets_component_files.json'): changed_assets = True if (basename == 'VERSION'): @@ -38,6 +51,15 @@ def CheckVersion(input_api, output_api): input_api.os_path.dirname(input_api.AffectedFiles()[0].LocalPath()), 'VERSION') + if changed_asset_files['google_chrome'] != changed_asset_files['chromium']: + return [ + output_api.PresubmitError( + 'Must have same asset files for %s in \'%s\'.' % + (changed_asset_files.keys(), + input_api.os_path.dirname( + input_api.AffectedFiles()[0].LocalPath()))) + ] + if changed_version and (not old_version or not new_version): return [ output_api.PresubmitError( @@ -61,8 +83,8 @@ def CheckVersion(input_api, output_api): def CheckChangeOnUpload(input_api, output_api): - return CheckVersion(input_api, output_api) + return CheckVersionAndAssetParity(input_api, output_api) def CheckChangeOnCommit(input_api, output_api): - return CheckVersion(input_api, output_api) + return CheckVersionAndAssetParity(input_api, output_api) diff --git a/chromium/chrome/browser/resources/vr/assets/VERSION b/chromium/chrome/browser/resources/vr/assets/VERSION index 1dea3031bc3..75fb8d39112 100644 --- a/chromium/chrome/browser/resources/vr/assets/VERSION +++ b/chromium/chrome/browser/resources/vr/assets/VERSION @@ -1,2 +1,2 @@ MAJOR=1 -MINOR=2
\ No newline at end of file +MINOR=3
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr/assets/chromium/background.png b/chromium/chrome/browser/resources/vr/assets/chromium/background.png Binary files differnew file mode 100644 index 00000000000..0fb3bfc35fc --- /dev/null +++ b/chromium/chrome/browser/resources/vr/assets/chromium/background.png diff --git a/chromium/chrome/browser/resources/vr/assets/chromium/fullscreen_gradient.png b/chromium/chrome/browser/resources/vr/assets/chromium/fullscreen_gradient.png Binary files differnew file mode 100644 index 00000000000..04c5d88e646 --- /dev/null +++ b/chromium/chrome/browser/resources/vr/assets/chromium/fullscreen_gradient.png diff --git a/chromium/chrome/browser/resources/vr/assets/chromium/incognito_gradient.png b/chromium/chrome/browser/resources/vr/assets/chromium/incognito_gradient.png Binary files differnew file mode 100644 index 00000000000..4fb17499405 --- /dev/null +++ b/chromium/chrome/browser/resources/vr/assets/chromium/incognito_gradient.png diff --git a/chromium/chrome/browser/resources/vr/assets/chromium/normal_gradient.png b/chromium/chrome/browser/resources/vr/assets/chromium/normal_gradient.png Binary files differnew file mode 100644 index 00000000000..0fb3bfc35fc --- /dev/null +++ b/chromium/chrome/browser/resources/vr/assets/chromium/normal_gradient.png diff --git a/chromium/chrome/browser/resources/vr/assets/fullscreen_gradient.png.sha1 b/chromium/chrome/browser/resources/vr/assets/fullscreen_gradient.png.sha1 deleted file mode 100644 index 8286e261102..00000000000 --- a/chromium/chrome/browser/resources/vr/assets/fullscreen_gradient.png.sha1 +++ /dev/null @@ -1 +0,0 @@ -4945728b62a01aa712b5b0bacbf10d5a1a95bd80
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr/assets/background.png.sha1 b/chromium/chrome/browser/resources/vr/assets/google_chrome/background.png.sha1 index d3427cb5c7b..d3427cb5c7b 100644 --- a/chromium/chrome/browser/resources/vr/assets/background.png.sha1 +++ b/chromium/chrome/browser/resources/vr/assets/google_chrome/background.png.sha1 diff --git a/chromium/chrome/browser/resources/vr/assets/google_chrome/fullscreen_gradient.png.sha1 b/chromium/chrome/browser/resources/vr/assets/google_chrome/fullscreen_gradient.png.sha1 new file mode 100644 index 00000000000..201ca21adc8 --- /dev/null +++ b/chromium/chrome/browser/resources/vr/assets/google_chrome/fullscreen_gradient.png.sha1 @@ -0,0 +1 @@ +be61cbcdc981d5592455d3b7a14b97e14d3acac4
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr/assets/google_chrome/incognito_gradient.png.sha1 b/chromium/chrome/browser/resources/vr/assets/google_chrome/incognito_gradient.png.sha1 new file mode 100644 index 00000000000..1282cf18759 --- /dev/null +++ b/chromium/chrome/browser/resources/vr/assets/google_chrome/incognito_gradient.png.sha1 @@ -0,0 +1 @@ +6b0e506b19b79ec1be4963a9bd92ab8b92ccca1c
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr/assets/google_chrome/normal_gradient.png.sha1 b/chromium/chrome/browser/resources/vr/assets/google_chrome/normal_gradient.png.sha1 new file mode 100644 index 00000000000..30a79ad3c93 --- /dev/null +++ b/chromium/chrome/browser/resources/vr/assets/google_chrome/normal_gradient.png.sha1 @@ -0,0 +1 @@ +bbe1145ae7778d7d50b02c54fd1bf4b62bbc04db
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr/assets/incognito_gradient.png.sha1 b/chromium/chrome/browser/resources/vr/assets/incognito_gradient.png.sha1 deleted file mode 100644 index 88f59906b07..00000000000 --- a/chromium/chrome/browser/resources/vr/assets/incognito_gradient.png.sha1 +++ /dev/null @@ -1 +0,0 @@ -a80f18117d1e8820404d50cdf49ba6bf6c3c69f0
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr/assets/normal_gradient.png.sha1 b/chromium/chrome/browser/resources/vr/assets/normal_gradient.png.sha1 deleted file mode 100644 index 425681fc101..00000000000 --- a/chromium/chrome/browser/resources/vr/assets/normal_gradient.png.sha1 +++ /dev/null @@ -1 +0,0 @@ -9888994181e2fa6cf6b510c02f21e183ad8efc46
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr/assets/push_assets_component.py b/chromium/chrome/browser/resources/vr/assets/push_assets_component.py index b259a6da904..b47f275ad0d 100755 --- a/chromium/chrome/browser/resources/vr/assets/push_assets_component.py +++ b/chromium/chrome/browser/resources/vr/assets/push_assets_component.py @@ -42,8 +42,8 @@ def main(): assets_dir = os.path.dirname(os.path.abspath(__file__)) files = [] - with open( - os.path.join(assets_dir, 'vr_assets_component_files.json')) as json_file: + with open(os.path.join(assets_dir, + 'vr_assets_component_files.json')) as json_file: files = json.load(json_file) version = None @@ -61,16 +61,20 @@ def main(): zip_path = os.path.join(zip_dir, 'vr-assets.zip') os.makedirs(zip_dir) + zip_files = [] with zipfile.ZipFile(zip_path, 'w') as zip: for file in files: file_path = os.path.join(assets_dir, file) zip.write(file_path, os.path.basename(file_path), zipfile.ZIP_DEFLATED) + for info in zip.infolist(): + zip_files.append(info.filename) # Upload component. command = ['gsutil', 'cp', '-nR', '.', DEST_BUCKET] PrintInfo('Going to run the following command', [' '.join(command)]) PrintInfo('In directory', [temp_dir]) PrintInfo('Which pushes the following file', [zip_path]) + PrintInfo('Which contains the files', zip_files) if raw_input('\nAre you sure (y/N) ').lower() != 'y': print 'aborting' diff --git a/chromium/chrome/browser/resources/vr/assets/vr_assets_component_files.json b/chromium/chrome/browser/resources/vr/assets/vr_assets_component_files.json index 7601c295253..f58922a30a3 100644 --- a/chromium/chrome/browser/resources/vr/assets/vr_assets_component_files.json +++ b/chromium/chrome/browser/resources/vr/assets/vr_assets_component_files.json @@ -1,6 +1,6 @@ [ - "background.png", - "fullscreen_gradient.png", - "incognito_gradient.png", - "normal_gradient.png" + "google_chrome/background.png", + "google_chrome/fullscreen_gradient.png", + "google_chrome/incognito_gradient.png", + "google_chrome/normal_gradient.png" ]
\ No newline at end of file diff --git a/chromium/chrome/browser/resources/vr_shell/OWNERS b/chromium/chrome/browser/resources/vr_shell/OWNERS deleted file mode 100644 index 0fa23cfe0db..00000000000 --- a/chromium/chrome/browser/resources/vr_shell/OWNERS +++ /dev/null @@ -1,3 +0,0 @@ -file://chrome/browser/android/vr_shell/OWNERS - -# COMPONENT: UI>Browser>VR diff --git a/chromium/chrome/browser/resources/vr_shell/ddcontroller.glb b/chromium/chrome/browser/resources/vr_shell/ddcontroller.glb Binary files differdeleted file mode 100644 index fa28d6b1962..00000000000 --- a/chromium/chrome/browser/resources/vr_shell/ddcontroller.glb +++ /dev/null diff --git a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_app.png b/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_app.png Binary files differdeleted file mode 100644 index 77594782ad3..00000000000 --- a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_app.png +++ /dev/null diff --git a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_idle.png b/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_idle.png Binary files differdeleted file mode 100644 index 5928f21896c..00000000000 --- a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_idle.png +++ /dev/null diff --git a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_system.png b/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_system.png Binary files differdeleted file mode 100644 index d4bae158510..00000000000 --- a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_system.png +++ /dev/null diff --git a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_touchpad.png b/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_touchpad.png Binary files differdeleted file mode 100644 index 7be802ee886..00000000000 --- a/chromium/chrome/browser/resources/vr_shell/tex/ddcontroller_touchpad.png +++ /dev/null diff --git a/chromium/chrome/browser/resources/vr_shell_resources.grd b/chromium/chrome/browser/resources/vr_shell_resources.grd deleted file mode 100644 index 06de759f2f6..00000000000 --- a/chromium/chrome/browser/resources/vr_shell_resources.grd +++ /dev/null @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<grit latest_public_release="0" current_release="1" output_all_resource_defines="false"> - <outputs> - <output filename="grit/vr_shell_resources.h" type="rc_header"> - <emit emit_type='prepend'></emit> - </output> - <output filename="vr_shell_resources.pak" type="data_package" /> - </outputs> - <release seq="1"> - <includes> - <!-- TODO(vollick): add conditionals for test-specific generic assets (see crbug.com/743687) --> - <include name="IDR_VR_SHELL_DDCONTROLLER_MODEL" file="vr_shell\ddcontroller.glb" type="BINDATA" /> - <include name="IDR_VR_SHELL_DDCONTROLLER_IDLE_TEXTURE" file="vr_shell\tex\ddcontroller_idle.png" type="BINDATA" /> - <include name="IDR_VR_SHELL_DDCONTROLLER_APP_PATCH" file="vr_shell\tex\ddcontroller_app.png" type="BINDATA" /> - <include name="IDR_VR_SHELL_DDCONTROLLER_TOUCHPAD_PATCH" file="vr_shell\tex\ddcontroller_touchpad.png" type="BINDATA" /> - <include name="IDR_VR_SHELL_DDCONTROLLER_SYSTEM_PATCH" file="vr_shell\tex\ddcontroller_system.png" type="BINDATA" /> - </includes> - </release> -</grit> |