blob: 6c030b12c6fd9cc0d98b0eea7ab07352856bc536 [file] [log] [blame]
/* 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-prefs' exposes a singleton model of Chrome settings and
* preferences, which listens to changes to Chrome prefs whitelisted in
* chrome.settingsPrivate. When changing prefs in this element's 'prefs'
* property via the UI, the singleton model tries to set those preferences in
* Chrome. Whether or not the calls to settingsPrivate.setPref succeed, 'prefs'
* is eventually consistent with the Chrome pref store.
*/
import {assert} from '//resources/js/assert.m.js';
import {html, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {CrSettingsPrefs} from './prefs_types.js';
/**
* Checks whether two values are recursively equal. Only compares serializable
* data (primitives, serializable arrays and serializable objects).
* @param {*} val1 Value to compare.
* @param {*} val2 Value to compare with val1.
* @return {boolean} True if the values are recursively equal.
*/
function deepEqual(val1, val2) {
if (val1 === val2) {
return true;
}
if (Array.isArray(val1) || Array.isArray(val2)) {
if (!Array.isArray(val1) || !Array.isArray(val2)) {
return false;
}
return arraysEqual(
/** @type {!Array} */ (val1),
/** @type {!Array} */ (val2));
}
if (val1 instanceof Object && val2 instanceof Object) {
return objectsEqual(val1, val2);
}
return false;
}
/**
* @param {!Array} arr1
* @param {!Array} arr2
* @return {boolean} True if the arrays are recursively equal.
*/
function arraysEqual(arr1, arr2) {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (!deepEqual(arr1[i], arr2[i])) {
return false;
}
}
return true;
}
/**
* @param {!Object} obj1
* @param {!Object} obj2
* @return {boolean} True if the objects are recursively equal.
*/
function objectsEqual(obj1, obj2) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
for (let i = 0; i < keys1.length; i++) {
const key = keys1[i];
if (!deepEqual(obj1[key], obj2[key])) {
return false;
}
}
return true;
}
/**
* Returns a recursive copy of the value.
* @param {*} val Value to copy. Should be a primitive or only contain
* serializable data (primitives, serializable arrays and
* serializable objects).
* @return {*} A deep copy of the value.
*/
function deepCopy(val) {
if (!(val instanceof Object)) {
return val;
}
return Array.isArray(val) ? deepCopyArray(/** @type {!Array} */ (val)) :
deepCopyObject(val);
}
/**
* @param {!Array} arr
* @return {!Array} Deep copy of the array.
*/
function deepCopyArray(arr) {
const copy = [];
for (let i = 0; i < arr.length; i++) {
copy.push(deepCopy(arr[i]));
}
return copy;
}
/**
* @param {!Object} obj
* @return {!Object} Deep copy of the object.
*/
function deepCopyObject(obj) {
const copy = {};
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
copy[key] = deepCopy(obj[key]);
}
return copy;
}
/** @polymer */
export class SettingsPrefsElement extends PolymerElement {
static get is() {
return 'settings-prefs';
}
static get properties() {
return {
/**
* Object containing all preferences, for use by Polymer controls.
* @type {Object|undefined}
*/
prefs: {
type: Object,
notify: true,
},
/**
* Map of pref keys to values representing the state of the Chrome
* pref store as of the last update from the API.
* @type {Object<*>}
* @private
*/
lastPrefValues_: {
type: Object,
value() {
return {};
},
},
};
}
static get observers() {
return [
'prefsChanged_(prefs.*)',
];
}
constructor() {
super();
/** @private {SettingsPrivate} */
this.settingsApi_ = /** @type {SettingsPrivate} */ (chrome.settingsPrivate);
/** @private {boolean} */
this.initialized_ = false;
if (!CrSettingsPrefs.deferInitialization) {
this.initialize();
}
}
/** @override */
disconnectedCallback() {
super.disconnectedCallback();
CrSettingsPrefs.resetForTesting();
}
/**
* @param {SettingsPrivate=} opt_settingsApi SettingsPrivate implementation
* to use (chrome.settingsPrivate by default).
*/
initialize(opt_settingsApi) {
// Only initialize once (or after resetForTesting() is called).
if (this.initialized_) {
return;
}
this.initialized_ = true;
if (opt_settingsApi) {
this.settingsApi_ = opt_settingsApi;
}
/** @private {function(!Array<!chrome.settingsPrivate.PrefObject>)} */
this.boundPrefsChanged_ = this.onSettingsPrivatePrefsChanged_.bind(this);
this.settingsApi_.onPrefsChanged.addListener(this.boundPrefsChanged_);
this.settingsApi_.getAllPrefs(
this.onSettingsPrivatePrefsFetched_.bind(this));
}
/**
* @param {!{path: string}} e
* @private
*/
prefsChanged_(e) {
// |prefs| can be directly set or unset in tests.
if (!CrSettingsPrefs.isInitialized || e.path === 'prefs') {
return;
}
const key = this.getPrefKeyFromPath_(e.path);
const prefStoreValue = this.lastPrefValues_[key];
const prefObj = /** @type {chrome.settingsPrivate.PrefObject} */ (
this.get(key, this.prefs));
// If settingsPrivate already has this value, ignore it. (Otherwise,
// a change event from settingsPrivate could make us call
// settingsPrivate.setPref and potentially trigger an IPC loop.)
if (!deepEqual(prefStoreValue, prefObj.value)) {
// <if expr="chromeos">
this.dispatchEvent(new CustomEvent('user-action-setting-change', {
bubbles: true,
composed: true,
detail: {prefKey: key, prefValue: prefObj.value}
}));
// </if>
this.settingsApi_.setPref(
key, prefObj.value,
/* pageId */ '',
/* callback */ this.setPrefCallback_.bind(this, key));
}
}
/**
* Called when prefs in the underlying Chrome pref store are changed.
* @param {!Array<!chrome.settingsPrivate.PrefObject>} prefs
* The prefs that changed.
* @private
*/
onSettingsPrivatePrefsChanged_(prefs) {
if (CrSettingsPrefs.isInitialized) {
this.updatePrefs_(prefs);
}
}
/**
* Called when prefs are fetched from settingsPrivate.
* @param {!Array<!chrome.settingsPrivate.PrefObject>} prefs
* @private
*/
onSettingsPrivatePrefsFetched_(prefs) {
this.updatePrefs_(prefs);
CrSettingsPrefs.setInitialized();
}
/**
* Checks the result of calling settingsPrivate.setPref.
* @param {string} key The key used in the call to setPref.
* @param {boolean} success True if setting the pref succeeded.
* @private
*/
setPrefCallback_(key, success) {
if (!success) {
this.refresh(key);
}
}
/**
* Get the current pref value from chrome.settingsPrivate to ensure the UI
* stays up to date.
* @param {string} key
*/
refresh(key) {
this.settingsApi_.getPref(key, pref => {
this.updatePrefs_([pref]);
});
}
/**
* Builds an object structure for the provided |path| within |prefsObject|,
* ensuring that names that already exist are not overwritten. For example:
* "a.b.c" -> a = {};a.b={};a.b.c={};
* @param {string} path Path to the new pref value.
* @param {*} value The value to expose at the end of the path.
* @param {Object} prefsObject The prefs object to add the path to.
* @private
*/
updatePrefPath_(path, value, prefsObject) {
const parts = path.split('.');
let cur = prefsObject;
for (let part; parts.length && (part = parts.shift());) {
if (!parts.length) {
// last part, set the value.
cur[part] = value;
} else if (part in cur) {
cur = cur[part];
} else {
cur = cur[part] = {};
}
}
}
/**
* Updates the prefs model with the given prefs.
* @param {!Array<!chrome.settingsPrivate.PrefObject>} newPrefs
* @private
*/
updatePrefs_(newPrefs) {
// Use the existing prefs object or create it.
const prefs = this.prefs || {};
newPrefs.forEach(function(newPrefObj) {
// Use the PrefObject from settingsPrivate to create a copy in
// lastPrefValues_ at the pref's key.
this.lastPrefValues_[newPrefObj.key] = deepCopy(newPrefObj.value);
if (!deepEqual(this.get(newPrefObj.key, prefs), newPrefObj)) {
// Add the pref to |prefs|.
this.updatePrefPath_(newPrefObj.key, newPrefObj, prefs);
// If this.prefs already exists, notify listeners of the change.
if (prefs === this.prefs) {
this.notifyPath('prefs.' + newPrefObj.key, newPrefObj);
}
}
}, this);
if (!this.prefs) {
this.prefs = prefs;
}
}
/**
* Given a 'property-changed' path, returns the key of the preference the
* path refers to. E.g., if the path of the changed property is
* 'prefs.search.suggest_enabled.value', the key of the pref that changed is
* 'search.suggest_enabled'.
* @param {string} path
* @return {string}
* @private
*/
getPrefKeyFromPath_(path) {
// Skip the first token, which refers to the member variable (this.prefs).
const parts = path.split('.');
assert(parts.shift() === 'prefs', 'Path doesn\'t begin with \'prefs\'');
for (let i = 1; i <= parts.length; i++) {
const key = parts.slice(0, i).join('.');
// The lastPrefValues_ keys match the pref keys.
if (this.lastPrefValues_.hasOwnProperty(key)) {
return key;
}
}
return '';
}
/**
* Resets the element so it can be re-initialized with a new prefs state.
*/
resetForTesting() {
if (!this.initialized_) {
return;
}
this.prefs = undefined;
this.lastPrefValues_ = {};
this.initialized_ = false;
// Remove the listener added in initialize().
this.settingsApi_.onPrefsChanged.removeListener(this.boundPrefsChanged_);
this.settingsApi_ =
/** @type {SettingsPrivate} */ (chrome.settingsPrivate);
}
}
customElements.define(SettingsPrefsElement.is, SettingsPrefsElement);