chromium/chrome/browser/resources/ash/settings/os_files_page/google_drive_subpage.ts

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

import 'chrome://resources/ash/common/cr_elements/action_link.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/localized_link/localized_link.js';
import 'chrome://resources/ash/common/cr_elements/policy/cr_policy_pref_indicator.js';
import 'chrome://resources/js/action_link.js';
import '../settings_shared.css.js';
import '../settings_vars.css.js';
import '//resources/polymer/v3_0/paper-tooltip/paper-tooltip.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assertNotReached} from 'chrome://resources/js/assert.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 {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {RouteObserverMixin} from '../common/route_observer_mixin.js';
import {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, routes} from '../router.js';

import {GoogleDriveBrowserProxy, GoogleDrivePageCallbackRouter, GoogleDrivePageHandlerRemote, Stage, Status} from './google_drive_browser_proxy.js';
import {getTemplate} from './google_drive_subpage.html.js';

const SettingsGoogleDriveSubpageElementBase =
    I18nMixin(PrefsMixin(DeepLinkingMixin(RouteObserverMixin(PolymerElement))));

/**
 * The preference containing the value whether Google Drive is disabled or not.
 */
const GOOGLE_DRIVE_DISABLED_PREF = 'gdata.disabled';

/**
 * The preference containing the value whether bulk pinning is enabled or not.
 */
const GOOGLE_DRIVE_BULK_PINNING_ENABLED_PREF = 'drivefs.bulk_pinning_enabled';

/**
 * A list of possible confirmation dialogs that may be shown.
 */
export enum ConfirmationDialogType {
  DISCONNECT = 'disconnect',
  BULK_PINNING_DISABLE = 'bulk-pinning-disable',
  BULK_PINNING_LISTING_FILES = 'bulk-pinning-listing-files',
  BULK_PINNING_NOT_ENOUGH_SPACE = 'bulk-pinning-not-enough-space',
  BULK_PINNING_UNEXPECTED_ERROR = 'bulk-pinning-unexpected-error',
  BULK_PINNING_CLEAN_UP_STORAGE = 'bulk-pinning-clean-up-storage',
  BULK_PINNING_OFFLINE = 'bulk-pinning-offline',
  NONE = 'none',
}

/**
 * When the pinned size is not still calculating or unknown.
 */
enum ContentCacheSizeType {
  UNKNOWN = 'unknown',
  CALCULATING = 'calculating',
}

export class SettingsGoogleDriveSubpageElement extends
    SettingsGoogleDriveSubpageElementBase {
  constructor() {
    super();
    this.proxy_ = GoogleDriveBrowserProxy.getInstance();
  }

  static get is() {
    return 'settings-google-drive-subpage';
  }

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

  static get properties() {
    return {
      /**
       * Used by DeepLinkingMixin to focus this page's deep links.
       */
      supportedSettingIds: {
        type: Object,
        value: () => new Set<Setting>(
            [Setting.kGoogleDriveRemoveAccess, Setting.kGoogleDriveFileSync]),
      },

      /**
       * Ensures the data binding is updated on the UI when
       * `contentCacheSize_` is updated.
       */
      contentCacheSize_: String,

      /**
       * Ensures the showSpinner variable is bound to the parent element and
       * updates are propagated as the spinner element is in the parent element.
       */
      showSpinner: {
        type: Boolean,
        notify: true,
        value: false,
      },

      /**
       * Indicates whether the `DriveFsBulkPinning` flag is enabled.
       */
      isDriveFsBulkPinningEnabled_: {
        type: Boolean,
        readonly: true,
        value: () => loadTimeData.getBoolean('enableDriveFsBulkPinning'),
      },

      /**
       * Indicates whether the `DriveFsMirroring` flag is enabled.
       */
      isDriveFsMirrorSyncEnabled_: {
        type: Boolean,
        readonly: true,
        value: () => loadTimeData.getBoolean('enableDriveFsMirrorSync'),
      },
    };
  }

  /**
   * Observe the state of `prefs.gdata.disabled` if it gets changed from another
   * location (e.g. enterprise policy).
   */
  static get observers() {
    return [
      `updateDriveDisabled_(prefs.${GOOGLE_DRIVE_DISABLED_PREF}.value)`,
      `updateBulkPinningVisible_(prefs.drivefs.bulk_pinning.visible.value)`,
    ];
  }

  /**
   * Reflects the state of `prefs.gdata.disabled` pref.
   */
  private driveDisabled_: boolean;

  /**
   * Reflects the state of `prefs.drivefs.bulk_pinning.visible` pref.
   */
  private bulkPinningVisible_: boolean;

  /**
   * A connection with the browser process to send/receive messages.
   */
  private proxy_: GoogleDriveBrowserProxy;

  /**
   * Keeps track of the latest response about bulk pinning from the page
   * handler.
   */
  private bulkPinningStatus_: Status|null = null;

  /**
   * If the underlying service is unavailable, this will get set to true.
   */
  private bulkPinningServiceUnavailable_: boolean = false;

  /**
   * Maps the dialogType_ property.
   */
  private dialogType_: ConfirmationDialogType = ConfirmationDialogType.NONE;

  /**
   * Keeps track of the last requested total content cache size.
   */
  private contentCacheSize_: string|ContentCacheSizeType =
      ContentCacheSizeType.CALCULATING;

  /**
   * The number of files that have currently been listed, this count is the one
   * displayed in the UI which gets updated every 5s from the source at
   * bulkPinningStatus_.listedFiles.
   */
  private listedFiles_: bigint = 0n;

  /**
   * The interval to update listedFiles_.
   */
  private updateListedFilesInterval_: number|undefined = undefined;

  /**
   * Whether to show the spinner in the top right of the settings page.
   */
  private showSpinner: boolean = false;

  private updateContentCacheSizeInterval_: number;

  private isDriveFsBulkPinningEnabled_: boolean;

  /**
   * Returns the browser proxy page handler (to invoke functions).
   */
  get pageHandler(): GoogleDrivePageHandlerRemote {
    return this.proxy_.handler;
  }

  /**
   * Returns the browser proxy callback router (to receive async messages).
   */
  get callbackRouter(): GoogleDrivePageCallbackRouter {
    return this.proxy_.observer;
  }

  /**
   * Returns the required space that is currently stored or -1 of no value. Used
   * for testing.
   */
  get requiredSpace(): string {
    return this.bulkPinningStatus_?.requiredSpace || '-1';
  }

  /**
   * Returns the free space that is currently stored or -1 of no value.
   * Used for testing.
   */
  get freeSpace(): string {
    return this.bulkPinningStatus_?.freeSpace || '-1';
  }

  /**
   * Returns the total pinned size stored.
   * Used for testing.
   */
  get contentCacheSize(): string {
    return this.contentCacheSize_;
  }

  /**
   * Returns the current number of listed files.
   * Used for testing.
   */
  get listedFiles(): bigint {
    return this.listedFiles_;
  }

  /**
   * Returns the current confirmation dialog showing.
   */
  get dialogType(): ConfirmationDialogType {
    return this.dialogType_;
  }

  /**
   * Returns the current bulk pinning stage, or `undefined` if not defined.
   */
  get stage(): Stage|undefined {
    return this.bulkPinningStatus_?.stage;
  }

  override connectedCallback(): void {
    super.connectedCallback();
    this.callbackRouter.onServiceUnavailable.addListener(
        this.onServiceUnavailable_.bind(this));
    this.callbackRouter.onProgress.addListener(this.onProgress_.bind(this));
  }

  override disconnectedCallback(): void {
    clearInterval(this.updateContentCacheSizeInterval_);
  }

  /**
   * Invoked when the underlying service is not longer available.
   */
  private onServiceUnavailable_(): void {
    this.bulkPinningServiceUnavailable_ = true;
    clearInterval(this.updateListedFilesInterval_);
    this.updateListedFilesInterval_ = undefined;
  }

  /**
   * Invoked when progress has occurred with the underlying pinning operation.
   * This could also end up in an error state (e.g. no free space).
   */
  private onProgress_(status: Status): void {
    this.bulkPinningServiceUnavailable_ = false;

    if (status.stage !== this.stage ||
        status.freeSpace !== this.bulkPinningStatus_?.freeSpace ||
        status.requiredSpace !== this.bulkPinningStatus_?.requiredSpace ||
        status.listedFiles !== this.bulkPinningStatus_?.listedFiles) {
      this.bulkPinningStatus_ = status;

      if (!this.updateListedFilesInterval_ &&
          status.stage === Stage.kListingFiles) {
        this.listedFiles_ = this.bulkPinningStatus_?.listedFiles || 0n;
        this.updateListedFilesInterval_ = setInterval(() => {
          this.listedFiles_ = this.bulkPinningStatus_?.listedFiles || 0n;
        }, 5000);
      }
    }

    if (status.stage !== Stage.kListingFiles) {
      this.stopUpdatingListedFilesAndClearDialog_();
    }

    let requiredSpace: number;
    try {
      requiredSpace = parseInt(status.requiredSpace);
    } catch (e) {
      console.error('Could not parse required space', e);
      return;
    }

    this.showSpinner = (status.stage === Stage.kSyncing && requiredSpace > 0);
  }

  /**
   * Whilst listing files an interval is maintained to not update the UI with
   * too many changes. When listing files has finished, ensure the interval is
   * cleared and the dialog is closed if it is kept open.
   */
  private stopUpdatingListedFilesAndClearDialog_(): void {
    clearInterval(this.updateListedFilesInterval_);
    this.updateListedFilesInterval_ = undefined;
    this.listedFiles_ = 0n;
    if (this.dialogType_ ===
        ConfirmationDialogType.BULK_PINNING_LISTING_FILES) {
      this.dialogType_ = ConfirmationDialogType.NONE;
    }
  }

  /**
   * Retrieves the total pinned size of items in Drive and stores the total.
   */
  private async updateContentCacheSize_(): Promise<void> {
    if (!this.contentCacheSize_) {
      // Only set the total pinned size to calculating on the first update.
      this.contentCacheSize_ = ContentCacheSizeType.CALCULATING;
    }
    const {size} = await this.pageHandler.getContentCacheSize();
    if (size) {
      this.contentCacheSize_ = size;
      return;
    }
    this.contentCacheSize_ = ContentCacheSizeType.UNKNOWN;
  }

  /**
   * Invoked when the `prefs.gdata.disabled` preference changes value.
   */
  private updateDriveDisabled_(disabled: boolean): void {
    this.driveDisabled_ = disabled;
    if (disabled) {
      this.showSpinner = false;
    }
  }

  /**
   * Invoked when the `prefs.drivefs.bulk_pinning.visible` preference changes
   * value.
   */
  private updateBulkPinningVisible_(visible: boolean): void {
    this.bulkPinningVisible_ = visible;
  }

  private and_(a: boolean, b: boolean): boolean {
    return a && b;
  }

  override currentRouteChanged(route: Route, _oldRoute?: Route): void {
    // Does not apply to this page.
    if (route !== routes.GOOGLE_DRIVE) {
      clearInterval(this.updateContentCacheSizeInterval_);
      return;
    }

    this.onNavigated();
  }

  /**
   * Invokes methods when the route is navigated to.
   */
  onNavigated(): void {
    this.attemptDeepLink();
    this.pageHandler.calculateRequiredSpace();
    this.updateContentCacheSize_();
    clearInterval(this.updateContentCacheSizeInterval_);
    this.updateContentCacheSizeInterval_ =
        setInterval(this.updateContentCacheSize_.bind(this), 5000);
  }

  private getDriveAccountStatusLabel_(): TrustedHTML {
    return this.driveDisabled_ ?
        this.i18nAdvanced('googleDriveReconnectAs', {attrs: ['id']}) :
        this.i18nAdvanced('googleDriveSignedInAs', {attrs: ['id']});
  }

  /**
   * Returns the value for the button to Connect/Disconnect Google drive
   * depending on the current state.
   */
  private getConnectDisconnectButtonLabel_(): string {
    return this.driveDisabled_ ?
        this.i18n('googleDriveConnectLabel') :
        this.i18n('googleDriveRemoveDriveAccessButtonText');
  }

  /**
   * Returns the text representation of the total pinned size.
   */
  private getContentCacheSizeLabel_(): string {
    if (this.contentCacheSize_ === ContentCacheSizeType.CALCULATING) {
      return this.i18n('googleDriveOfflineClearCalculatingSubtitle');
    } else if (this.contentCacheSize_ === ContentCacheSizeType.UNKNOWN) {
      return this.i18n('googleDriveOfflineClearErrorSubtitle');
    }

    return this.i18n(
        'googleDriveOfflineStorageSpaceTaken', this.contentCacheSize_);
  }

  /**
   * Returns the text representation of the tooltip text when the "Clean up
   * storage" button is disabled.
   */
  private getCleanUpStorageDisabledTooltipText_(): string {
    if (this.contentCacheSize_ === ContentCacheSizeType.UNKNOWN ||
        this.contentCacheSize_ === ContentCacheSizeType.CALCULATING) {
      return this.i18n(
          'googleDriveCleanUpStorageDisabledUnknownStorageTooltip');
    }

    if (this.getPref(GOOGLE_DRIVE_BULK_PINNING_ENABLED_PREF).value &&
        this.contentCacheSize_ !== '0 B') {
      return this.i18n('googleDriveCleanUpStorageDisabledFileSyncTooltip');
    }

    return this.i18n('googleDriveCleanUpStorageDisabledTooltip');
  }

  /**
   * If Drive is disconnected, immediately update the preference. If Drive is
   * connected, show the confirmation dialog instead of immediately updating the
   * preference when the button is pressed.
   */
  private onConnectDisconnectClick_(): void {
    if (this.driveDisabled_) {
      this.setPrefValue(GOOGLE_DRIVE_DISABLED_PREF, false);
      return;
    }
    this.dialogType_ = ConfirmationDialogType.DISCONNECT;
  }

  /**
   * Update the `gdata.disabled` pref to `true` iff the "Disconnect" button was
   * pressed, all remaining actions (e.g. Cancel, ESC) should not update the
   * preference.
   */
  private async onDriveConfirmationDialogClose_(e: CustomEvent): Promise<void> {
    const closedDialogType = this.dialogType_;
    this.dialogType_ = ConfirmationDialogType.NONE;
    if (!e.detail.accept) {
      return;
    }

    switch (closedDialogType) {
      case ConfirmationDialogType.DISCONNECT:
        this.setPrefValue(GOOGLE_DRIVE_DISABLED_PREF, true);
        this.setPrefValue(GOOGLE_DRIVE_BULK_PINNING_ENABLED_PREF, false);
        break;
      case ConfirmationDialogType.BULK_PINNING_DISABLE:
        this.setPrefValue(GOOGLE_DRIVE_BULK_PINNING_ENABLED_PREF, false);
        break;
      case ConfirmationDialogType.BULK_PINNING_CLEAN_UP_STORAGE:
        await this.proxy_.handler.clearPinnedFiles();
        this.updateContentCacheSize_();
        break;
      default:
        // All other dialogs currently do not require any action (only a
        // cancellation) and so should not be reached.
        assertNotReached('Unknown acceptance criteria from dialog');
    }
  }

  /**
   * Returns the sublabel for the bulk pinning preference toggle. If the
   * required / free space has been calculated, includes the values in the
   * sublabel.
   */
  private getBulkPinningSubLabel_(): string {
    if (!this.bulkPinningStatus_ || this.stage !== Stage.kSuccess ||
        this.bulkPinningServiceUnavailable_) {
      return this.i18n('googleDriveFileSyncSubtitleWithoutStorage');
    }

    const {requiredSpace, freeSpace} = this.bulkPinningStatus_;
    return this.i18n(
        'googleDriveFileSyncSubtitleWithStorage',
        requiredSpace!,
        freeSpace!,
    );
  }

  /**
   * For the various dialogs that are defined in the HTML, only one should be
   * shown at all times. If the supplied type matches the requested type, show
   * the dialog.
   */
  private shouldShowConfirmationDialog_(
      type: ConfirmationDialogType, requestedType: string): boolean {
    return type === requestedType;
  }

  /**
   * Spawn a confirmation dialog to the user if they choose to disable the bulk
   * pinning feature, for enabling just update the preference.
   */
  private onToggleBulkPinning_(e: Event): void {
    const target = e.target as SettingsToggleButtonElement;
    const newValueAfterToggle =
        !this.getPref(GOOGLE_DRIVE_BULK_PINNING_ENABLED_PREF).value;

    if (newValueAfterToggle) {
      this.tryEnableBulkPinning_(target);
      return;
    }

    // Turning the preference off should first spawn a dialog to have the user
    // confirm that is what they want to do, leave the target as checked as the
    // user must confirm before the preference gets updated.
    target.checked = true;
    this.dialogType_ = ConfirmationDialogType.BULK_PINNING_DISABLE;
  }

  /**
   * Try to enable the bulk pinning toggle. If the `Stage` is in either in an
   * error OR in a state that can't be enabled (e.g. PausedOffline or
   * ListingFiles) then ensure the toggle isn't enabled, otherwise don't show a
   * dialog and enable immediately.
   */
  private tryEnableBulkPinning_(target: SettingsToggleButtonElement): void {
    target.checked = false;

    // When the device is offline, don't allow the user to enable the toggle.
    if (this.stage === Stage.kPausedOffline) {
      this.dialogType_ = ConfirmationDialogType.BULK_PINNING_OFFLINE;
      return;
    }

    // If currently enumerating the files, don't allow the user to enable file
    // sync until we're certain the corpus will fit on the device.
    if (this.stage === Stage.kListingFiles) {
      this.dialogType_ = ConfirmationDialogType.BULK_PINNING_LISTING_FILES;
      return;
    }

    if (this.bulkPinningStatus_?.isError) {
      // If there is not enough free space for the user to reliably turn on bulk
      // pinning, spawn a dialog.
      if (this.stage === Stage.kNotEnoughSpace) {
        this.dialogType_ = ConfirmationDialogType.BULK_PINNING_NOT_ENOUGH_SPACE;
        return;
      }

      // If an error occurs (that is not related to low disk space) surface an
      // unexpected error dialog.
      this.dialogType_ = ConfirmationDialogType.BULK_PINNING_UNEXPECTED_ERROR;
      return;
    }

    target.checked = true;
    this.setPrefValue(GOOGLE_DRIVE_BULK_PINNING_ENABLED_PREF, true);
    this.proxy_.handler.recordBulkPinningEnabledMetric();
  }

  /**
   * Returns true if the "Clean up storage" button should be enabled.
   */
  private shouldEnableCleanUpStorageButton_(
      status: Status|null, cacheSize: string|ContentCacheSizeType): boolean {
    const stage = status?.stage;
    return (stage === undefined || stage === Stage.kStopped ||
            stage === Stage.kSuccess || stage === Stage.kNotEnoughSpace ||
            stage === Stage.kCannotGetFreeSpace ||
            stage === Stage.kCannotListFiles ||
            stage === Stage.kCannotEnableDocsOffline) &&
        cacheSize !== ContentCacheSizeType.UNKNOWN &&
        cacheSize !== ContentCacheSizeType.CALCULATING && cacheSize !== '0 B';
  }

  /**
   * Returns the string used in the confirmation dialog when cleaning the users
   * offline storage, this includes the total GB used by offline files.
   */
  private getCleanUpStorageConfirmationDialogBody(): TrustedHTML {
    return this.i18nAdvanced('googleDriveOfflineCleanStorageDialogBody', {
      tags: ['a'],
      substitutions: [
        this.contentCacheSize_!,
        this.i18n('googleDriveCleanUpStorageLearnMoreLink'),
      ],
    });
  }

  private getListingFilesDialogBody_(): string {
    return this.i18n(
        'googleDriveFileSyncListingFilesItemsFoundBody',
        this.listedFiles_.toLocaleString());
  }

  /**
   * When the "Clean up storage" button is clicked, should not clean up
   * immediately but show the confirmation dialog first.
   */
  private onCleanUpStorage_(): void {
    this.dialogType_ = ConfirmationDialogType.BULK_PINNING_CLEAN_UP_STORAGE;
  }

  /** Gets the mirror sync sub label. */
  private getMirrorSyncDescription_(): string {
    // TODO(b/338158838) Get size of MyFiles.
    // TODO(b/338158838) Get available space on Google Drive.
    return this.i18n('googleDriveMirrorSyncDescription');
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-google-drive-subpage': SettingsGoogleDriveSubpageElement;
  }
}

customElements.define(
    SettingsGoogleDriveSubpageElement.is, SettingsGoogleDriveSubpageElement);