chromium/chrome/browser/resources/chromeos/accessibility/common/local_storage.ts

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Class to handle accessing/storing/caching local storage data.
 */
import {TestImportManager} from './testing/test_import_manager.js';

type StorageChange = chrome.storage.StorageChange;

export class LocalStorage {
  private values_: Record<string, any>|null = null;
  private keyCallbacks_: Record<string, Array<(value: any) => void>> = {};
  private static instance?: LocalStorage;

  constructor(onInit: (localStorage: LocalStorage) => void) {
    chrome.storage.local.get(
        undefined /* get all values */,
        (values: {[key: string]: any}) => this.onInitialGet_(values, onInit));
    chrome.storage.local.onChanged.addListener(
        (updates: {[key: string]: StorageChange}) => this.update_(updates));
  }

  // ========== Static methods ==========

  static async init(): Promise<void> {
    if (LocalStorage.instance) {
      throw new Error(
          'LocalStorage.init() should be called at most once in each ' +
          'browser context.');
    }

    LocalStorage.instance =
        await new Promise(resolve => new LocalStorage(resolve));
    LocalStorage.migrateFromLocalStorage_();
  }

  static addListenerForKey(key: string, callback: (value: any) => void): void {
    // TODO(b/314203187): Not nulls asserted, check these to make sure they are
    // correct.
    if (!LocalStorage.instance!.keyCallbacks_[key]) {
      LocalStorage.instance!.keyCallbacks_[key] = [];
    }
    if (callback) {
      LocalStorage.instance!.keyCallbacks_[key].push(callback);
    }
  }

  static get(key: string, defaultValue: any = undefined): any {
    LocalStorage.assertReady_();
    const value = LocalStorage.instance!.values_![key];
    if (value !== undefined) {
      return value;
    }
    return defaultValue;
  }

  static getTypeChecked(key: string, type: string, defaultValue?: any): any {
    const value = LocalStorage.get(key, defaultValue);
    if (typeof value === type) {
      return value;
    }
    throw new Error(
        'Value in LocalStorage for key "' + key + '" is not a ' + type);
  }

  static getBoolean(key: string, defaultValue?: boolean): boolean {
    const value = LocalStorage.getTypeChecked(key, 'boolean', defaultValue);
    return Boolean(value);
  }

  static getNumber(key: string, defaultValue?: number): number {
    const value = LocalStorage.getTypeChecked(key, 'number', defaultValue);
    if (isNaN(value)) {
      throw new Error('Value in LocalStorage for key "' + key + '" is NaN');
    }
    return Number(value);
  }

  static getString(key: string, defaultValue?: string): string {
    const value = LocalStorage.getTypeChecked(key, 'string', defaultValue);
    return String(value);
  }

  static remove(key: string): void {
    LocalStorage.assertReady_();
    chrome.storage.local.remove(key);
    delete LocalStorage.instance!.values_![key];
  }

  static set(key: string, val: any): void {
    LocalStorage.assertReady_();
    chrome.storage.local.set({[key]: val});
    LocalStorage.instance!.values_![key] = val;
  }

  static toggle(key: string): void {
    LocalStorage.assertReady_();
    const val = LocalStorage.get(key);
    if (typeof val !== 'boolean') {
      throw new Error('Cannot toggle value of non-boolean setting');
    }
    LocalStorage.set(key, !val);
  }

  // ========= Private Methods ==========

  private onInitialGet_(values: Record<string, any>, onInit: (localStorage: LocalStorage) => void): void {
    this.values_ = values;
    onInit(this);
  }

  private update_(updates: Record<string, chrome.storage.StorageChange>): void {
    for (const key in updates) {
      // TODO(b/314203187): Not null asserted, check these to make sure they are
      // correct.
      this.values_![key] = updates[key].newValue;
      if (this.keyCallbacks_[key]) {
        this.keyCallbacks_[key].forEach(
            callback => callback(updates[key].newValue));
      }
    }
  }

  private static migrateFromLocalStorage_(): void {
    // Save the keys, because otherwise the values are shifting under us as we
    // iterate.
    const keys: string[] = [];
    for (let i = 0; i < localStorage.length; i++) {
      keys.push(localStorage.key(i)!);
    }

    for (const key of keys) {
      let val = localStorage[key];
      delete localStorage[key];

      if (val === String(true)) {
        val = true;
      } else if (val === String(false)) {
        val = false;
      } else if (/^\d+$/.test(val)) {
        // A string that with at least one digit and no other characters is an
        // integer.
        val = parseInt(val, 10);
      } else if (/^[\d]+[.][\d]+$/.test(val)) {
        // A string with at least one digit, followed by a dot, followed by at
        // least one digit is a floating point number.
        //
        // When converting floats to strings, v8 adds the leading 0 if there
        // were no digits before the decimal. E.g. String(.2) === "0.2"
        //
        // Similarly, integer values followed by a dot and any number of zeroes
        // are stored without a decimal and will be handled by the above case.
        // E.g. String(1.0) === "1"
        val = parseFloat(val);
      } else if (/^{.*}$/.test(val) || /^\[.*]$/.test(val)) {
        // If a string begins and ends with curly or square brackets, try to
        // convert it to an object/array. JSON.parse() will throw an error if
        // the string is not valid JSON syntax. In that case, the variable value
        // will remain unchanged (with a type of 'string').
        try {
          val = JSON.parse(val);
        } catch (syntaxError) {
        }
      }

      // We cannot call LocalStorage.set() because assertReady will fail.
      chrome.storage.local.set({[key]: val});
      LocalStorage.instance!.values_![key] = val;
    }
  }

  private static assertReady_(): void {
    if (!LocalStorage.instance || !LocalStorage.instance.values_) {
      throw new Error(
          'LocalStorage should not be accessed until initialization is ' +
          'complete.');
    }
  }
}

TestImportManager.exportForTesting(LocalStorage);