chromium/chrome/browser/resources/ash/settings/crostini_page/crostini_subpage.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
 * 'crostini-subpage' is the settings subpage for managing Crostini.
 */

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import '../controls/settings_toggle_button.js';
import '../settings_shared.css.js';
import '../guest_os/guest_os_confirmation_dialog.js';
import './crostini_disk_resize_dialog.js';
import './crostini_disk_resize_confirmation_dialog.js';
import './crostini_port_forwarding.js';
import './crostini_extra_containers.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_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 {castExists} from '../assert_extras.js';
import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {RouteOriginMixin} from '../common/route_origin_mixin.js';
import {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import {TERMINA_VM_TYPE} from '../guest_os/guest_os_browser_proxy.js';
import {recordSettingChange} from '../metrics_recorder.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';

import {CrostiniBrowserProxy, CrostiniBrowserProxyImpl, CrostiniDiskInfo} from './crostini_browser_proxy.js';
import {getTemplate} from './crostini_subpage.html.js';

/**
 * The current confirmation state.
 */
enum ConfirmationState {
  NOT_CONFIRMED = 'notConfirmed',
  CONFIRMED = 'confirmed',
}

const SettingsCrostiniSubpageElementBase = DeepLinkingMixin(
    RouteOriginMixin(PrefsMixin(WebUiListenerMixin(PolymerElement))));

export class SettingsCrostiniSubpageElement extends
    SettingsCrostiniSubpageElementBase {
  static get is() {
    return 'settings-crostini-subpage';
  }

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

  static get properties() {
    return {
      /**
       * Whether export / import UI should be displayed.
       */
      showCrostiniExportImport_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('showCrostiniExportImport');
        },
      },

      showArcAdbSideloading_: {
        type: Boolean,
        computed: 'and_(isArcAdbSideloadingSupported_, isAndroidEnabled_)',
      },

      isArcAdbSideloadingSupported_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('arcAdbSideloadingSupported');
        },
      },

      showCrostiniPortForwarding_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('showCrostiniPortForwarding');
        },
      },

      showCrostiniExtraContainers_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('showCrostiniExtraContainers');
        },
      },

      isAndroidEnabled_: {
        type: Boolean,
      },

      /**
       * Whether the uninstall options should be displayed.
       */
      hideCrostiniUninstall_: {
        type: Boolean,
        computed: 'or_(installerShowing_, upgraderDialogShowing_)',
      },

      /**
       * Whether the button to launch the Crostini container upgrade flow should
       * be shown.
       */
      showCrostiniContainerUpgrade_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('showCrostiniContainerUpgrade');
        },
      },

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

      installerShowing_: {
        type: Boolean,
      },

      upgraderDialogShowing_: {
        type: Boolean,
      },

      /**
       * Whether the button to launch the Crostini container upgrade flow should
       * be disabled.
       */
      disableUpgradeButton_: {
        type: Boolean,
        computed: 'or_(installerShowing_, upgraderDialogShowing_)',
      },

      /**
       * Whether the disk resizing dialog is visible or not
       */
      showDiskResizeDialog_: {
        type: Boolean,
        value: false,
      },

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

      diskSizeLabel_: {
        type: String,
        value: loadTimeData.getString('crostiniDiskSizeCalculating'),
      },

      diskResizeButtonLabel_: {
        type: String,
        value: loadTimeData.getString('crostiniDiskResizeShowButton'),
      },

      diskResizeButtonAriaLabel_: {
        type: String,
        value: loadTimeData.getString('crostiniDiskResizeShowButtonAriaLabel'),
      },

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

      /**
       * Used by DeepLinkingMixin to focus this page's deep links.
       */
      supportedSettingIds: {
        type: Object,
        value: () => new Set<Setting>([
          Setting.kUninstallCrostini,
          Setting.kCrostiniDiskResize,
          Setting.kCrostiniMicAccess,
          Setting.kCrostiniContainerUpgrade,
        ]),
      },
    };
  }

  static get observers() {
    return [
      'onCrostiniEnabledChanged_(prefs.crostini.enabled.value)',
      'onArcEnabledChanged_(prefs.arc.enabled.value)',
    ];
  }

  private browserProxy_: CrostiniBrowserProxy;
  private canDiskResize_: boolean;
  private diskResizeButtonAriaLabel_: string;
  private diskResizeButtonLabel_: string;
  private diskResizeConfirmationState_: ConfirmationState;
  private diskSizeLabel_: string;
  private installerShowing_: boolean;
  private isAndroidEnabled_: boolean;
  private isDiskUserChosenSize_: boolean;
  private showCrostiniContainerUpgrade_: boolean;
  private showCrostiniMicPermissionDialog_: boolean;
  private showDiskResizeConfirmationDialog_: boolean;
  private showDiskResizeDialog_: boolean;
  private upgraderDialogShowing_: boolean;

  constructor() {
    super();

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

    this.isDiskUserChosenSize_ = false;

    this.diskResizeConfirmationState_ = ConfirmationState.NOT_CONFIRMED;

    this.browserProxy_ = CrostiniBrowserProxyImpl.getInstance();
  }

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

    this.addWebUiListener(
        'crostini-installer-status-changed', (status: boolean) => {
          this.installerShowing_ = status;
        });
    this.addWebUiListener(
        'crostini-upgrader-status-changed', (status: boolean) => {
          this.upgraderDialogShowing_ = status;
        });
    this.addWebUiListener(
        'crostini-container-upgrade-available-changed',
        (canUpgrade: boolean) => {
          this.showCrostiniContainerUpgrade_ = canUpgrade;
        });
    this.browserProxy_.requestCrostiniInstallerStatus();
    this.browserProxy_.requestCrostiniUpgraderDialogStatus();
    this.browserProxy_.requestCrostiniContainerUpgradeAvailable();
    this.loadDiskInfo_();
  }

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

    const r = routes;
    this.addFocusConfig(r.CROSTINI_SHARED_PATHS, '#crostiniSharedPathsRow');
    this.addFocusConfig(
        r.CROSTINI_SHARED_USB_DEVICES, '#crostiniSharedUsbDevicesRow');
    this.addFocusConfig(r.CROSTINI_EXPORT_IMPORT, '#crostiniExportImportRow');
    this.addFocusConfig(r.CROSTINI_ANDROID_ADB, '#crostiniEnableArcAdbRow');
    this.addFocusConfig(
        r.CROSTINI_PORT_FORWARDING, '#crostiniPortForwardingRow');
    this.addFocusConfig(
        r.CROSTINI_EXTRA_CONTAINERS, '#crostiniExtraContainersRow');
  }

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

    // Does not apply to this page.
    if (newRoute !== this.route) {
      return;
    }

    this.attemptDeepLink();
  }

  private onCrostiniEnabledChanged_(enabled: boolean): void {
    if (!enabled &&
        Router.getInstance().currentRoute === routes.CROSTINI_DETAILS) {
      Router.getInstance().navigateToPreviousRoute();
    }
    if (enabled) {
      // The disk size or type could have changed due to the user reinstalling
      // Crostini, update our info.
      this.loadDiskInfo_();
    }
  }

  private onArcEnabledChanged_(enabled: boolean): void {
    this.isAndroidEnabled_ = enabled;
  }

  private onExportImportClick_(): void {
    Router.getInstance().navigateTo(routes.CROSTINI_EXPORT_IMPORT);
  }

  private onEnableArcAdbClick_(): void {
    Router.getInstance().navigateTo(routes.CROSTINI_ANDROID_ADB);
  }

  private loadDiskInfo_(): void {
    this.browserProxy_
        .getCrostiniDiskInfo(TERMINA_VM_TYPE, /*requestFullInfo=*/ false)
        .then(
            diskInfo => {
              if (diskInfo.succeeded) {
                this.setResizeLabels_(diskInfo);
              }
            },
            reason => {
              console.warn(`Unable to get info: ${reason}`);
            });
  }

  private setResizeLabels_(diskInfo: CrostiniDiskInfo): void {
    this.canDiskResize_ = diskInfo.canResize;
    if (!this.canDiskResize_) {
      this.diskSizeLabel_ =
          loadTimeData.getString('crostiniDiskResizeNotSupportedSubtext');
      return;
    }
    this.isDiskUserChosenSize_ = diskInfo.isUserChosenSize;
    if (this.isDiskUserChosenSize_) {
      if (diskInfo.ticks) {
        this.diskSizeLabel_ = diskInfo.ticks[diskInfo.defaultIndex].label;
      }
      this.diskResizeButtonLabel_ =
          loadTimeData.getString('crostiniDiskResizeShowButton');
      this.diskResizeButtonAriaLabel_ =
          loadTimeData.getString('crostiniDiskResizeShowButtonAriaLabel');
    } else {
      this.diskSizeLabel_ = loadTimeData.getString(
          'crostiniDiskResizeDynamicallyAllocatedSubtext');
      this.diskResizeButtonLabel_ =
          loadTimeData.getString('crostiniDiskReserveSizeButton');
      this.diskResizeButtonAriaLabel_ =
          loadTimeData.getString('crostiniDiskReserveSizeButtonAriaLabel');
    }
  }

  private onDiskResizeClick_(): void {
    if (!this.isDiskUserChosenSize_ &&
        this.diskResizeConfirmationState_ !== ConfirmationState.CONFIRMED) {
      this.showDiskResizeConfirmationDialog_ = true;
      return;
    }
    this.showDiskResizeDialog_ = true;
  }

  private onDiskResizeDialogClose_(): void {
    this.showDiskResizeDialog_ = false;
    this.diskResizeConfirmationState_ = ConfirmationState.NOT_CONFIRMED;
    // DiskInfo could have changed.
    this.loadDiskInfo_();
  }

  private onDiskResizeConfirmationDialogClose_(): void {
    // The on_cancel is followed by on_close, so check cancel didn't happen
    // first.
    if (this.showDiskResizeConfirmationDialog_) {
      this.diskResizeConfirmationState_ = ConfirmationState.CONFIRMED;
      this.showDiskResizeConfirmationDialog_ = false;
      this.showDiskResizeDialog_ = true;
    }
  }

  private onDiskResizeConfirmationDialogCancel_(): void {
    this.showDiskResizeConfirmationDialog_ = false;
  }

  /**
   * Shows a confirmation dialog when removing crostini.
   */
  private onRemoveClick_(): void {
    this.browserProxy_.requestRemoveCrostini();
    recordSettingChange(Setting.kUninstallCrostini);
  }

  /**
   * Shows the upgrade flow dialog.
   */
  private onContainerUpgradeClick_(): void {
    this.browserProxy_.requestCrostiniContainerUpgradeView();
    recordSettingChange(Setting.kCrostiniContainerUpgrade);
  }

  private onSharedPathsClick_(): void {
    Router.getInstance().navigateTo(routes.CROSTINI_SHARED_PATHS);
  }

  private onSharedUsbDevicesClick_(): void {
    Router.getInstance().navigateTo(routes.CROSTINI_SHARED_USB_DEVICES);
  }

  private onPortForwardingClick_(): void {
    Router.getInstance().navigateTo(routes.CROSTINI_PORT_FORWARDING);
  }

  private onExtraContainersClick_(): void {
    Router.getInstance().navigateTo(routes.CROSTINI_EXTRA_CONTAINERS);
  }

  private getMicToggle_(): SettingsToggleButtonElement {
    return castExists(
        this.shadowRoot!.querySelector<SettingsToggleButtonElement>(
            '#crostini-mic-permission-toggle'));
  }

  /**
   * If a change to the mic settings requires Crostini to be restarted, a
   * dialog is shown.
   */
  private async onMicPermissionChange_(): Promise<void> {
    if (await this.browserProxy_.checkCrostiniIsRunning()) {
      this.showCrostiniMicPermissionDialog_ = true;
    } else {
      this.getMicToggle_().sendPrefChange();
    }
  }

  private onCrostiniMicPermissionDialogClose_(e: CustomEvent): void {
    const toggle = this.getMicToggle_();
    if (e.detail.accepted) {
      toggle.sendPrefChange();
      this.browserProxy_.shutdownCrostini();
    } else {
      toggle.resetToPrefValue();
    }

    this.showCrostiniMicPermissionDialog_ = false;
  }

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

  private or_(a: boolean, b: boolean): boolean {
    return a || b;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-crostini-subpage': SettingsCrostiniSubpageElement;
  }
}

customElements.define(
    SettingsCrostiniSubpageElement.is, SettingsCrostiniSubpageElement);