chromium/chrome/browser/resources/ash/settings/device_page/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.

import '/shared/settings/prefs/prefs.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import '../settings_shared.css.js';
import './storage_external.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrLinkRowElement} from 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {isCrostiniSupported, isExternalStorageEnabled, isSkyVaultEnabled} from '../common/load_time_booleans.js';
import {RouteOriginMixin} from '../common/route_origin_mixin.js';
import {Route, Router, routes} from '../router.js';

import {DevicePageBrowserProxy, DevicePageBrowserProxyImpl, StorageSpaceState} from './device_page_browser_proxy.js';
import {getTemplate} from './storage.html.js';

interface StorageSizeStat {
  availableSize: string;
  usedSize: string;
  usedRatio: number;
  spaceState: StorageSpaceState;
}

export interface SettingsStorageElement {
  $: {
    availableLabelArea: HTMLElement,
    browsingDataSize: CrLinkRowElement,
    inUseLabelArea: HTMLElement,
  };
}

const SettingsStorageElementBase =
    I18nMixin(PrefsMixin(RouteOriginMixin(WebUiListenerMixin(PolymerElement))));

export class SettingsStorageElement extends SettingsStorageElementBase {
  static get is() {
    return 'settings-storage';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      prefs: {
        type: Object,
        notify: true,
      },

      showCrostiniStorage_: {
        type: Boolean,
        value: false,
      },

      isDriveEnabled_: {
        type: Boolean,
        value: true,
      },

      isEphemeralUser_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isCryptohomeDataEphemeral');
        },
      },

      isExternalStorageEnabled_: {
        type: Boolean,
        value: () => {
          return isExternalStorageEnabled();
        },
      },

      showOtherUsers_: {
        type: Boolean,
        // Initialize showOtherUsers_ to false if the user is ephemeral.
        value() {
          return !loadTimeData.getBoolean('isCryptohomeDataEphemeral');
        },
      },

      isSkyVaultEnabled_: {
        type: Boolean,
        value() {
          return isSkyVaultEnabled();
        },
      },

      /**
       * Sublabel for the MyFiles section, later it will be updated with the
       * calculated size.
       */
      myFilesSizeSubLabel_: {
        type: String,
        value(this: SettingsStorageElement) {
          return this.i18n('storageSizeComputing');
        },
      },

      /**
       * Sublabel for storage encryption label.
       */
      storageEncryptionSubLabel_: {
        type: String,
        value(this: SettingsStorageElement) {
          return this.i18n('storageSizeComputing');
        },
      },

      sizeStat_: Object,
    };
  }

  static get observers() {
    return [
      'handleCrostiniEnabledChanged_(prefs.crostini.enabled.value)',
      'handleDriveDisabledChanged_(prefs.gdata.disabled.value)',
    ];
  }

  private browserProxy_: DevicePageBrowserProxy;
  private isEphemeralUser_: boolean;
  private showCrostiniStorage_: boolean;
  private isDriveEnabled_: boolean;
  private isExternalStorageEnabled_: boolean;
  private showOtherUsers_: boolean;
  private sizeStat_: StorageSizeStat;
  private updateTimerId_: number;
  private myFilesSizeSubLabel_: string;
  private storageEncryptionSubLabel_: string;
  private readonly isSkyVaultEnabled_: boolean;

  constructor() {
    super();

    /** RouteOriginMixin override */
    this.route = routes.STORAGE;

    /**
     * Timer ID for periodic update.
     */
    this.updateTimerId_ = -1;

    this.browserProxy_ = DevicePageBrowserProxyImpl.getInstance();
  }

  override connectedCallback(): void {
    super.connectedCallback();

    this.addWebUiListener(
        'storage-size-stat-changed',
        (sizeStat: StorageSizeStat) => this.handleSizeStatChanged_(sizeStat));
    this.addWebUiListener(
        'storage-my-files-size-changed',
        (size: string) => this.handleMyFilesSizeChanged_(size));
    this.addWebUiListener(
        'storage-browsing-data-size-changed',
        (size: string) => this.handleBrowsingDataSizeChanged_(size));
    this.addWebUiListener(
        'storage-apps-size-changed',
        (size: string) => this.handleAppsSizeChanged_(size));
    this.addWebUiListener(
        'storage-drive-offline-size-changed',
        (size: string) => this.handleDriveOfflineSizeChanged_(size));
    this.addWebUiListener(
        'storage-crostini-size-changed',
        (size: string) => this.handleCrostiniSizeChanged_(size));
    if (this.showOtherUsers_) {
      this.addWebUiListener(
          'storage-other-users-size-changed',
          (size: string, noOtherUsers: boolean) =>
              this.handleOtherUsersSizeChanged_(size, noOtherUsers));
      this.addWebUiListener(
          'storage-system-size-changed',
          (size: string) => this.handleSystemSizeChanged_(size));
    }
    if (!this.isEphemeralUser_) {
      this.browserProxy_.getStorageEncryptionInfo().then(
          encryptionInfo => {
            this.storageEncryptionSubLabel_ = encryptionInfo;
          },
          reason => {
            console.warn(`Unable to get info: ${reason}`);
          });
    }
  }

  override ready(): void {
    super.ready();

    const r = routes;
    this.addFocusConfig(r.CROSTINI_DETAILS, '#crostiniSize');
    this.addFocusConfig(r.ACCOUNTS, '#otherUsersSize');
    this.addFocusConfig(
        r.EXTERNAL_STORAGE_PREFERENCES, '#externalStoragePreferences');
    this.addFocusConfig(r.APP_MANAGEMENT, '#appsSize');
  }

  override currentRouteChanged(newRoute: Route, oldRoute?: Route): void {
    super.currentRouteChanged(newRoute, oldRoute);

    if (newRoute !== this.route) {
      return;
    }
    this.onPageShown_();
  }

  private onPageShown_(): void {
    // Updating storage information can be expensive (e.g. computing directory
    // sizes recursively), so we delay this operation until the page is shown.
    this.browserProxy_.updateStorageInfo();
    // We update the storage usage periodically when the overlay is visible.
    this.startPeriodicUpdate_();
  }

  /**
   * Handler for tapping the MyFiles item.
   */
  private onMyFilesClick_(): void {
    if (this.localUserFilesAllowed_(
            this.getPref('filebrowser.local_user_files_allowed').value)) {
      this.browserProxy_.openMyFiles();
    }
  }

  /**
   * Handler for tapping the "Browsing data" item.
   */
  private onBrowsingDataClick_(): void {
    this.browserProxy_.openBrowsingDataSettings();
  }

  /**
   * Handler for tapping the "Apps and Extensions" item.
   */
  private onAppsClick_(): void {
    Router.getInstance().navigateTo(
        routes.APP_MANAGEMENT,
        /* dynamicParams= */ undefined, /* removeSearch= */ true);
  }

  /**
   * Handler for tapping the "Offline files" item.
   */
  private onDriveOfflineClick_(): void {
    Router.getInstance().navigateTo(
        routes.GOOGLE_DRIVE,
        /* dynamicParams= */ undefined, /* removeSearch= */ true);
  }

  /**
   * Handler for tapping the "Linux storage" item.
   */
  private onCrostiniClick_(): void {
    Router.getInstance().navigateTo(
        routes.CROSTINI_DETAILS, /* dynamicParams= */ undefined,
        /* removeSearch= */ true);
  }

  /**
   * Handler for tapping the "Other users" item.
   */
  private onOtherUsersClick_(): void {
    Router.getInstance().navigateTo(
        routes.ACCOUNTS,
        /* dynamicParams= */ undefined, /* removeSearch= */ true);
  }

  /**
   * Handler for tapping the "External storage preferences" item.
   */
  private onExternalStoragePreferencesClick_(): void {
    Router.getInstance().navigateTo(routes.EXTERNAL_STORAGE_PREFERENCES);
  }

  private handleSizeStatChanged_(sizeStat: StorageSizeStat): void {
    this.sizeStat_ = sizeStat;
    this.$.inUseLabelArea.style.width = (sizeStat.usedRatio * 100) + '%';
    this.$.availableLabelArea.style.width =
        ((1 - sizeStat.usedRatio) * 100) + '%';
  }

  /**
   * @param size Formatted string representing the size of MyFiles.
   */
  private handleMyFilesSizeChanged_(size: string): void {
    this.myFilesSizeSubLabel_ = size;
  }

  /**
   * @param size Formatted string representing the size of Browsing data.
   */
  private handleBrowsingDataSizeChanged_(size: string): void {
    this.$.browsingDataSize.subLabel = size;
  }

  /**
   * @param size Formatted string representing the size of Apps and
   *     extensions storage.
   */
  private handleAppsSizeChanged_(size: string): void {
    this.shadowRoot!.querySelector<CrLinkRowElement>('#appsSize')!.subLabel =
        size;
  }

  /**
   * @param size Formatted string representing the size of pinned files in
   *     Google Drive.
   */
  private handleDriveOfflineSizeChanged_(size: string): void {
    if (!this.shouldShowOfflineFilesRow_()) {
      return;
    }
    this.shadowRoot!.querySelector<CrLinkRowElement>(
                        '#driveOfflineSize')!.subLabel = size;
  }

  /**
   * @param size Formatted string representing the size of Crostini storage.
   */
  private handleCrostiniSizeChanged_(size: string): void {
    if (this.showCrostiniStorage_) {
      this.shadowRoot!.querySelector<CrLinkRowElement>(
                          '#crostiniSize')!.subLabel = size;
    }
  }

  /**
   * @param size Formatted string representing the size of Other users.
   * @param noOtherUsers True if there is no other registered users
   *     on the device.
   */
  private handleOtherUsersSizeChanged_(size: string, noOtherUsers: boolean):
      void {
    if (this.isEphemeralUser_ || noOtherUsers) {
      this.showOtherUsers_ = false;
      return;
    }
    this.showOtherUsers_ = true;
    this.shadowRoot!.querySelector<CrLinkRowElement>(
                        '#otherUsersSize')!.subLabel = size;
  }

  /**
   * @param size Formatted string representing the System size.
   */
  private handleSystemSizeChanged_(size: string): void {
    this.shadowRoot!.getElementById('systemSizeSubLabel')!.innerText = size;
  }

  /**
   * @param enabled True if Crostini is enabled.
   */
  private handleCrostiniEnabledChanged_(enabled: boolean): void {
    this.showCrostiniStorage_ = enabled && isCrostiniSupported();
  }

  /**
   * Handles showing or hiding the Offline files row if Drive is disabled.
   */
  private handleDriveDisabledChanged_(disabled: boolean): void {
    this.isDriveEnabled_ = !disabled;
  }

  /**
   * Whether to show the Offline files row or not.
   */
  private shouldShowOfflineFilesRow_(): boolean {
    return this.isDriveEnabled_;
  }

  /**
   * Starts periodic update for storage usage.
   */
  private startPeriodicUpdate_(): void {
    // We update the storage usage every 5 seconds.
    if (this.updateTimerId_ === -1) {
      this.updateTimerId_ = window.setInterval(() => {
        if (Router.getInstance().currentRoute !== routes.STORAGE) {
          this.stopPeriodicUpdate_();
          return;
        }
        this.browserProxy_.updateStorageInfo();
      }, 5000);
    }
  }

  /**
   * Stops periodic update for storage usage.
   */
  private stopPeriodicUpdate_(): void {
    if (this.updateTimerId_ !== -1) {
      window.clearInterval(this.updateTimerId_);
      this.updateTimerId_ = -1;
    }
  }

  /**
   * Returns true if the remaining space is low, but not critically low.
   * @param spaceState Status about the remaining space.
   */
  private isSpaceLow_(spaceState: StorageSpaceState): boolean {
    return spaceState === StorageSpaceState.LOW;
  }

  /**
   * Returns true if the remaining space is critically low.
   * @param spaceState Status about the remaining space.
   */
  private isSpaceCriticallyLow_(spaceState: StorageSpaceState): boolean {
    return spaceState === StorageSpaceState.CRITICALLY_LOW;
  }

  /**
   * Computes class name of the bar based on the remaining space size.
   * @param spaceState Status about the remaining space.
   */
  private getBarClass_(spaceState: StorageSpaceState): string {
    switch (spaceState) {
      case StorageSpaceState.LOW:
        return 'space-low';
      case StorageSpaceState.CRITICALLY_LOW:
        return 'space-critically-low';
      default:
        return '';
    }
  }

  private roundTo2DecimalPoints_(n: number): string {
    return n.toFixed(2);
  }

  /**
   * Checks feature flags and pref values to determine whether storing user
   * files locally is allowed.
   * @param prefValue The value of the local_user_files_allowed pref. Ignored if
   *     feature flags are disabled.
   * @returns Whether local user files are allowed.
   */
  private localUserFilesAllowed_(prefValue: boolean): boolean {
    // If SkyVault is disabled, we don't care about the pref value.
    return !this.isSkyVaultEnabled_ || prefValue;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-storage': SettingsStorageElement;
  }
}

customElements.define(SettingsStorageElement.is, SettingsStorageElement);