feat(options-provider): allow to listen for changes

This CL adds logic so features/scripts can subscribe to changes to the
options configuration.

Change-Id: I64fbee8a8c6a253fa93b2ffb7a3adf50292c4160
diff --git a/src/common/options/OptionsConfiguration.ts b/src/common/options/OptionsConfiguration.ts
new file mode 100644
index 0000000..78f3692
--- /dev/null
+++ b/src/common/options/OptionsConfiguration.ts
@@ -0,0 +1,32 @@
+import {
+  OptionCodename,
+  OptionsValues,
+  optionCodenames,
+} from './optionsPrototype';
+
+/**
+ * Representation of a specific configuration of the option values.
+ */
+export class OptionsConfiguration {
+  constructor(public optionsValues: OptionsValues) {}
+
+  getOptionValue<O extends OptionCodename>(option: O): OptionsValues[O] {
+    return this.optionsValues[option];
+  }
+
+  isEnabled(option: OptionCodename): boolean {
+    const value = this.getOptionValue(option);
+    return value === true;
+  }
+
+  isEqualTo(otherConfiguration: OptionsConfiguration) {
+    for (const option of optionCodenames) {
+      const thisValue = this.getOptionValue(option);
+      const otherValue = otherConfiguration.getOptionValue(option);
+      if (thisValue !== otherValue) {
+        return false;
+      }
+    }
+    return true;
+  }
+}
diff --git a/src/common/options/OptionsProvider.ts b/src/common/options/OptionsProvider.ts
index 36370a1..138da07 100644
--- a/src/common/options/OptionsProvider.ts
+++ b/src/common/options/OptionsProvider.ts
@@ -1,43 +1,110 @@
 import { Mutex, MutexInterface, withTimeout } from 'async-mutex';
 
 import { getOptions } from './optionsUtils';
-import { OptionCodename, OptionValues } from './optionsPrototype';
+import { OptionCodename, OptionsValues } from './optionsPrototype';
+import { OptionsConfiguration } from './OptionsConfiguration';
 
+// Prioritize reads before writes.
+const kReadPriority = 10;
+const kWritePriority = 0;
+
+/**
+ * Class which provides option values and a way to listen to option changes.
+ */
 export default class OptionsProvider {
-  private optionValues: OptionValues;
-  private isStale = true;
+  private optionsConfiguration: OptionsConfiguration;
   private mutex: MutexInterface = withTimeout(new Mutex(), 60 * 1000);
+  private listeners: Set<OptionsChangeListener> = new Set();
 
   constructor() {
-    // If the extension settings change, set the current cached value as stale.
-    // We could try only doing this only when we're sure it has changed, but
-    // there are many factors (if the user has changed it manually, if a kill
-    // switch was activated, etc.) so we'll do it every time.
+    this.listenForStorageChanges();
+    this.updateValues();
+  }
+
+  /**
+   * Sets up a listener to update the current cached configuration when there
+   * are changes to the underlying storage where options are persisted.
+   *
+   * We could try only doing this only when we're sure it has changed, but
+   * there are many factors (if the user has changed it manually, if a kill
+   * switch was activated, etc.) so we do it every time there is any change in
+   * the underlying storage.
+   */
+  private listenForStorageChanges() {
     chrome.storage.onChanged.addListener((_, areaName) => {
       if (areaName !== 'sync') return;
-      console.debug('[optionsWatcher] Marking options as stale.');
-      this.isStale = true;
+      console.debug('[OptionsProvider] Retrieving updated options.');
+      this.updateValues();
     });
   }
 
-  // Returns a promise resolving to the value of option |option|.
-  getOption<O extends OptionCodename>(option: O): Promise<OptionValues[O]> {
-    // When the cached value is marked as stale, it might be possible that there
-    // is a flood of calls to isEnabled(), which in turn causes a flood of calls
-    // to getOptions() because it takes some time for it to be marked as not
-    // stale. Thus, hiding the logic behind a mutex fixes this.
-    return this.mutex.runExclusive(async () => {
-      if (!this.isStale) return Promise.resolve(this.optionValues[option]);
-
-      this.optionValues = await getOptions();
-      this.isStale = false;
-      return this.optionValues[option];
-    });
+  private async updateValues() {
+    await this.mutex.runExclusive(async () => {
+      await this.nonSafeUpdateValues();
+    }, kWritePriority);
   }
 
-  // Returns a promise resolving to whether the |feature| is enabled.
-  async isEnabled(option: OptionCodename) {
-    const value = await this.getOption(option);
-    return value === true;
+  private async nonSafeUpdateValues() {
+    const previousConfiguration = this.optionsConfiguration;
+    const currentOptionsValues = await getOptions(null);
+    this.optionsConfiguration = new OptionsConfiguration(currentOptionsValues);
+
+    this.notifyListenersIfApplicable(previousConfiguration);
+  }
+
+  private async notifyListenersIfApplicable(
+    previousOptionsConfiguration: OptionsConfiguration,
+  ) {
+    if (
+      !previousOptionsConfiguration ||
+      this.optionsConfiguration.isEqualTo(previousOptionsConfiguration)
+    ) {
+      return;
+    }
+
+    for (const listener of this.listeners) {
+      listener(previousOptionsConfiguration, this.optionsConfiguration);
+    }
+  }
+
+  /**
+   * Returns the value of option |option|.
+   */
+  async getOptionValue<O extends OptionCodename>(
+    option: O,
+  ): Promise<OptionsValues[O]> {
+    return this.mutex.runExclusive(
+      () => this.optionsConfiguration.getOptionValue(option),
+      kReadPriority,
+    );
+  }
+
+  /**
+   * Returns whether |feature| is enabled.
+   */
+  async isEnabled(option: OptionCodename): Promise<boolean> {
+    return this.mutex.runExclusive(
+      () => this.optionsConfiguration.isEnabled(option),
+      kReadPriority,
+    );
+  }
+
+  async getOptionsValues(): Promise<OptionsValues> {
+    return this.mutex.runExclusive(
+      () => this.optionsConfiguration.optionsValues,
+      kReadPriority,
+    );
+  }
+
+  /**
+   * Adds a listener for changes in the options configuration.
+   */
+  addListener(listener: OptionsChangeListener) {
+    this.listeners.add(listener);
   }
 }
+
+export type OptionsChangeListener = (
+  previousOptionValues: OptionsConfiguration,
+  currentOptionValues: OptionsConfiguration,
+) => void;
diff --git a/src/common/options/optionsPrototype.ts b/src/common/options/optionsPrototype.ts
index 277ae4c..c42b696 100644
--- a/src/common/options/optionsPrototype.ts
+++ b/src/common/options/optionsPrototype.ts
@@ -247,7 +247,9 @@
   options.map((option) => [option.codename, option]),
 );
 
+export const optionCodenames = Object.keys(rawOptionConfigs) as OptionCodename[];
+
 export type OptionCodename = keyof typeof rawOptionConfigs;
-export type OptionValues = {
+export type OptionsValues = {
   [K in OptionCodename]: (typeof rawOptionConfigs)[K]['defaultValue'];
 };