chromium/chrome/browser/resources/ash/settings/internet_page/cellular_networks_list.ts

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

/**
 * @fileoverview Polymer element for displaying a summary of Cellular network
 * states
 */

import 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_icons.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_lazy_render/cr_lazy_render.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/ash/common/cr_elements/policy/cr_policy_indicator.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import '../os_settings_icons.css.js';
import './esim_install_error_dialog.js';

import {CellularSetupPageName} from 'chrome://resources/ash/common/cellular_setup/cellular_types.js';
import {ESimManagerListenerMixin} from 'chrome://resources/ash/common/cellular_setup/esim_manager_listener_mixin.js';
import {getEuicc} from 'chrome://resources/ash/common/cellular_setup/esim_manager_utils.js';
import {CrActionMenuElement} from 'chrome://resources/ash/common/cr_elements/cr_action_menu/cr_action_menu.js';
import {CrIconButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import {CrLazyRenderElement} from 'chrome://resources/ash/common/cr_elements/cr_lazy_render/cr_lazy_render.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/ash/common/load_time_data.m.js';
import {getSimSlotCount} from 'chrome://resources/ash/common/network/cellular_utils.js';
import {MojoInterfaceProviderImpl} from 'chrome://resources/ash/common/network/mojo_interface_provider.js';
import {NetworkList} from 'chrome://resources/ash/common/network/network_list_types.js';
import {OncMojo} from 'chrome://resources/ash/common/network/onc_mojo.js';
import {assert} from 'chrome://resources/js/assert.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import {ESimProfileProperties, ESimProfileRemote, EuiccRemote, ProfileInstallResult, ProfileState} from 'chrome://resources/mojo/chromeos/ash/services/cellular_setup/public/mojom/esim_manager.mojom-webui.js';
import {CrosNetworkConfigInterface, GlobalPolicy, InhibitReason} from 'chrome://resources/mojo/chromeos/services/network_config/public/mojom/cros_network_config.mojom-webui.js';
import {DeviceStateType, NetworkType} from 'chrome://resources/mojo/chromeos/services/network_config/public/mojom/network_types.mojom-webui.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {castExists} from '../assert_extras.js';
import {MultiDeviceBrowserProxyImpl} from '../multidevice_page/multidevice_browser_proxy.js';
import {MultiDeviceFeatureState, MultiDevicePageContentData} from '../multidevice_page/multidevice_constants.js';

import {getTemplate} from './cellular_networks_list.html.js';

declare global {
  interface HTMLElementEventMap {
    'install-profile': CustomEvent<{iccid: string}>;
  }
}

const CellularNetworksListElementBase =
    ESimManagerListenerMixin(WebUiListenerMixin(I18nMixin(PolymerElement)));

export class CellularNetworksListElement extends
    CellularNetworksListElementBase {
  static get is() {
    return 'cellular-networks-list' as const;
  }

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

  static get properties() {
    return {
      /**
       * The list of network state properties for the items to display.
       */
      networks: {
        type: Array,
        value() {
          return [];
        },
        observer: 'onNetworksListChanged_',
      },

      /**
       * Whether to show technology badge on mobile network icons.
       */
      showTechnologyBadge: Boolean,

      /**
       * Device state for the cellular network type.
       */
      cellularDeviceState: Object,

      isConnectedToNonCellularNetwork: {
        type: Boolean,
      },

      /**
       * If true, inhibited spinner can be shown, it will be shown
       * if true and cellular is inhibited.
       */
      canShowSpinner: {
        type: Boolean,
      },

      /**
       * Device state for the tether network type. This device state should be
       * used for instant tether networks.
       */
      tetherDeviceState: Object,

      globalPolicy: Object,

      /**
       * The list of eSIM network state properties for display.
       */
      eSimNetworks_: {
        type: Array,
        value() {
          return [];
        },
      },

      /**
       * Dictionary mapping pending eSIM profile iccids to pending eSIM
       * profiles.
       */
      profilesMap_: {
        type: Object,
        value() {
          return new Map();
        },
      },

      /**
       * The list of pending eSIM profiles to display after the list of eSIM
       * networks.
       */
      eSimPendingProfileItems_: {
        type: Array,
        value() {
          return [];
        },
      },

      /**
       * The list of pSIM network state properties for display.
       */
      pSimNetworks_: {
        type: Array,
        value() {
          return [];
        },
      },

      /**
       * The list of tether network state properties for display.
       */
      tetherNetworks_: {
        type: Array,
        value() {
          return [];
        },
      },

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

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

      /**
       * Euicc object representing the active euicc_ module on the device
       */
      euicc_: {
        type: Object,
        value: null,
      },

      /**
       * The current eSIM profile being installed.
       */
      installingESimProfile_: {
        type: Object,
        value: null,
      },

      /**
       * The error code returned when eSIM profile install attempt was made.
       */
      eSimProfileInstallError_: {
        type: Object,
        value: null,
      },

      /**
       * Multi-device page data used to determine if the tether section should
       * be shown or not.
       */
      multiDevicePageContentData_: {
        type: Object,
        value: null,
      },

      isDeviceInhibited_: {
        type: Boolean,
        computed: 'computeIsDeviceInhibited_(cellularDeviceState,' +
            'cellularDeviceState.inhibitReason)',
      },
      /**
       * Return true if instant hotspot rebrand feature flag is enabled
       */
      isInstantHotspotRebrandEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.valueExists('isInstantHotspotRebrandEnabled') &&
              loadTimeData.getBoolean('isInstantHotspotRebrandEnabled');
        },
      },
    };
  }

  canShowSpinner: boolean;
  cellularDeviceState: OncMojo.DeviceStateProperties|undefined;
  globalPolicy: GlobalPolicy|undefined;
  isConnectedToNonCellularNetwork: boolean;
  networks: OncMojo.NetworkStateProperties[];
  showTechnologyBadge: boolean;
  tetherDeviceState: OncMojo.DeviceStateProperties|undefined;

  private eSimPendingProfileItems_: NetworkList.CustomItemState[];
  private eSimProfileInstallError_: ProfileInstallResult|null;
  private eSimNetworks_: OncMojo.NetworkStateProperties[];
  private euicc_: EuiccRemote|null;
  private installingESimProfile_: ESimProfileRemote|null;
  private isDeviceInhibited_: boolean;
  private isInstantHotspotRebrandEnabled_: boolean;
  private multiDevicePageContentData_: MultiDevicePageContentData|null;
  private networkConfig_: CrosNetworkConfigInterface;
  private profilesMap_: Map<string, ESimProfileRemote>;
  private pSimNetworks_: OncMojo.NetworkStateProperties[];
  private shouldShowEidDialog_: boolean;
  private shouldShowInstallErrorDialog_: boolean;
  private tetherNetworks_: OncMojo.NetworkStateProperties[];

  constructor() {
    super();

    this.networkConfig_ =
        MojoInterfaceProviderImpl.getInstance().getMojoServiceRemote();
    this.fetchEuiccAndEsimPendingProfileList_();
  }

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

    this.addEventListener('install-profile', this.installProfile_);

    this.addWebUiListener(
        'settings.updateMultidevicePageContentData',
        this.onMultiDevicePageContentDataChanged_.bind(this));

    MultiDeviceBrowserProxyImpl.getInstance().getPageContentData().then(
        this.onMultiDevicePageContentDataChanged_.bind(this));
  }

  override onAvailableEuiccListChanged(): void {
    this.fetchEuiccAndEsimPendingProfileList_();
  }

  private fetchEuiccAndEsimPendingProfileList_(): void {
    getEuicc().then(euicc => {
      if (!euicc) {
        return;
      }
      this.euicc_ = euicc;
    });
  }

  /**
   * Return true if esim section should be shown.
   */
  private shouldShowEsimSection_(): boolean {
    if (!this.cellularDeviceState) {
      return false;
    }
    const {eSimSlots} = getSimSlotCount(this.cellularDeviceState);
    // Check both the SIM slot infos and the number of EUICCs because the former
    // comes from Shill and the latter from Hermes, so there may be instances
    // where one may be true while they other isn't.
    return !!this.euicc_ && eSimSlots > 0;
  }


  private async processEsimPendingProfiles_(profiles: ESimProfileRemote[]):
      Promise<void> {
    this.profilesMap_ = new Map();
    const eSimPendingProfilePromises =
        profiles.map(this.createEsimPendingProfilePromise_.bind(this));
    const eSimPendingProfileItems =
        await Promise.all(eSimPendingProfilePromises);
    this.eSimPendingProfileItems_ = eSimPendingProfileItems;
  }

  private async createEsimPendingProfilePromise_(profile: ESimProfileRemote):
      Promise<NetworkList.CustomItemState> {
    const response = await profile.getProperties();
    this.profilesMap_.set(response.properties.iccid, profile);
    return this.createEsimPendingProfileItem_(response.properties);
  }

  private createEsimPendingProfileItem_(properties: ESimProfileProperties):
      NetworkList.CustomItemState {
    return {
      customItemType: properties.state === ProfileState.kInstalling ?
          NetworkList.CustomItemType.ESIM_INSTALLING_PROFILE :
          NetworkList.CustomItemType.ESIM_PENDING_PROFILE,
      customItemName: mojoString16ToString(properties.name),
      customItemSubtitle: mojoString16ToString(properties.serviceProvider),
      polymerIcon: 'network:cellular-0',
      showBeforeNetworksList: false,
      customData: {
        iccid: properties.iccid,
      },
    };
  }

  private onNetworksListChanged_(): void {
    const pSimNetworks: OncMojo.NetworkStateProperties[] = [];
    const eSimNetworks: OncMojo.NetworkStateProperties[] = [];
    const tetherNetworks: OncMojo.NetworkStateProperties[] = [];

    for (const network of this.networks) {
      if (network.type === NetworkType.kTether) {
        tetherNetworks.push(network);
        continue;
      }

      if (network.typeState.cellular && network.typeState.cellular.eid) {
        eSimNetworks.push(network);
      } else {
        pSimNetworks.push(network);
      }
    }
    this.eSimNetworks_ = eSimNetworks;
    this.pSimNetworks_ = pSimNetworks;
    this.tetherNetworks_ = tetherNetworks;
  }

  private shouldShowNetworkSublist_(
      ...lists: NetworkList.NetworkListItemType[][]): boolean {
    const totalListLength = lists.reduce((accumulator, currentList) => {
      return accumulator + currentList.length;
    }, 0);
    return totalListLength > 0;
  }

  private shouldShowPsimSection_(
      pSimNetworks: OncMojo.NetworkStateProperties[],
      cellularDeviceState: OncMojo.DeviceStateProperties|undefined): boolean {
    const {pSimSlots} = getSimSlotCount(cellularDeviceState);
    if (pSimSlots > 0) {
      return true;
    }
    // Dual MBIM currently doesn't support eSIM hotswap (b/229619768), which
    // leads Hermes to always show two Eids after swap with pSIM. So, we should
    // also check if there's pSimNetworks available to work around this
    // limitation.
    return this.shouldShowNetworkSublist_(pSimNetworks);
  }

  private onMultiDevicePageContentDataChanged_(
      newData: MultiDevicePageContentData): void {
    this.multiDevicePageContentData_ = newData;
  }

  private shouldShowTetherSection_(pageContentData: MultiDevicePageContentData|
                                   null): boolean {
    if (!pageContentData) {
      return false;
    }
    if (this.isInstantHotspotRebrandEnabled_) {
      return false;
    }
    return pageContentData.instantTetheringState ===
        MultiDeviceFeatureState.ENABLED_BY_USER;
  }

  private onAddEsimLinkClicked_(event: CustomEvent<{event: Event}>): void {
    event.detail.event.preventDefault();
    event.stopPropagation();

    const showCellularSetupEvent = new CustomEvent('show-cellular-setup', {
      bubbles: true,
      composed: true,
      detail: {pageName: CellularSetupPageName.ESIM_FLOW_UI},
    });
    this.dispatchEvent(showCellularSetupEvent);
  }

  private onEsimDotsClick_(e: Event): void {
    const menu = this.shadowRoot!
                     .querySelector<CrLazyRenderElement<CrActionMenuElement>>(
                         '#menu')!.get();
    menu.showAt(e.target as HTMLElement);
  }

  private onShowEidDialogClick_(): void {
    const actionMenu =
        castExists(this.shadowRoot!.querySelector('cr-action-menu'));
    actionMenu.close();
    this.shouldShowEidDialog_ = true;
  }

  private onCloseEidDialog_(): void {
    this.shouldShowEidDialog_ = false;
  }

  private installProfile_(event: CustomEvent<{iccid: string}>): void {
    if (!this.isConnectedToNonCellularNetwork) {
      const event = new CustomEvent('show-error-toast', {
        bubbles: true,
        composed: true,
        detail: this.i18n('eSimNoConnectionErrorToast'),
      });
      this.dispatchEvent(event);
      return;
    }
    this.installingESimProfile_ =
        castExists(this.profilesMap_.get(event.detail.iccid));
    this.installingESimProfile_.installProfile('').then((response) => {
      if (response.result === ProfileInstallResult.kSuccess) {
        this.eSimProfileInstallError_ = null;
        this.installingESimProfile_ = null;
      } else {
        this.eSimProfileInstallError_ = response.result;
        this.showInstallErrorDialog_();
      }
    });
  }

  private showInstallErrorDialog_(): void {
    this.shouldShowInstallErrorDialog_ = true;
  }

  private onCloseInstallErrorDialog_(): void {
    this.shouldShowInstallErrorDialog_ = false;
  }

  private shouldShowAddEsimButton_(cellularDeviceState:
                                       OncMojo.DeviceStateProperties|
                                   undefined): boolean {
    assert(this.euicc_);
    return this.deviceIsEnabled_(cellularDeviceState);
  }

  private isAddEsimButtonDisabled_(
      cellularDeviceState: OncMojo.DeviceStateProperties|undefined,
      globalPolicy: GlobalPolicy): boolean {
    if (this.isDeviceInhibited_) {
      return true;
    }
    if (!this.deviceIsEnabled_(cellularDeviceState)) {
      return true;
    }
    if (!globalPolicy) {
      return false;
    }
    return globalPolicy.allowOnlyPolicyCellularNetworks;
  }

  /**
   * Return true if the policy indicator that next to the add cellular button
   * should be shown. This policy icon indicates the reason of disabling the
   * add cellular button.
   */
  private shouldShowAddEsimPolicyIcon_(globalPolicy: GlobalPolicy): boolean {
    return globalPolicy && globalPolicy.allowOnlyPolicyCellularNetworks;
  }

  private deviceIsEnabled_(cellularDeviceState: OncMojo.DeviceStateProperties|
                           undefined): boolean {
    return !!cellularDeviceState &&
        cellularDeviceState.deviceState === DeviceStateType.kEnabled;
  }

  private computeIsDeviceInhibited_(): boolean {
    if (!this.cellularDeviceState) {
      return false;
    }
    return OncMojo.deviceIsInhibited(this.cellularDeviceState);
  }

  private onAddEsimButtonClick_(): void {
    const event = new CustomEvent('show-cellular-setup', {
      bubbles: true,
      composed: true,
      detail: {pageName: CellularSetupPageName.ESIM_FLOW_UI},
    });
    this.dispatchEvent(event);
  }

  /*
   * Returns the add esim button. If the device does not have an EUICC, no eSIM
   * slot, or policies prohibit users from adding a network, null is returned.
   * @return {?HTMLElement}
   */
  getAddEsimButton(): CrIconButtonElement|null {
    return this.shadowRoot!.querySelector<CrIconButtonElement>(
        '#addESimButton');
  }

  private getInhibitedSubtextMessage_(): string {
    if (!this.cellularDeviceState) {
      return '';
    }

    const inhibitReason = this.cellularDeviceState.inhibitReason;

    switch (inhibitReason) {
      case InhibitReason.kInstallingProfile:
        return this.i18n('cellularNetworkInstallingProfile');
      case InhibitReason.kRenamingProfile:
        return this.i18n('cellularNetworkRenamingProfile');
      case InhibitReason.kRemovingProfile:
        return this.i18n('cellularNetworkRemovingProfile');
      case InhibitReason.kConnectingToProfile:
        return this.i18n('cellularNetworkConnectingToProfile');
      case InhibitReason.kRefreshingProfileList:
        return this.i18n('cellularNetworRefreshingProfileListProfile');
      case InhibitReason.kResettingEuiccMemory:
        return this.i18n('cellularNetworkResettingESim');
      case InhibitReason.kRequestingAvailableProfiles:
        return this.i18n('cellularNetworkRequestingAvailableProfiles');
    }

    return '';
  }

  private isInhibitedOrAffectedByPolicy_(): boolean {
    if (this.cellularDeviceState &&
        this.cellularDeviceState.inhibitReason !== undefined &&
        this.cellularDeviceState.inhibitReason !==
            InhibitReason.kNotInhibited) {
      return true;
    }
    return !!this.globalPolicy &&
        this.globalPolicy.allowOnlyPolicyCellularNetworks;
  }

  /**
   * Return true IFF there are no eSIM profiles installed and we are not
   * installing a profile, refreshing the profile list, or requesting available
   * profiles.
   */
  private shouldShowNoEsimNetworksMessage_(): boolean {
    if (this.cellularDeviceState &&
        this.cellularDeviceState.inhibitReason !== undefined) {
      const inhibitReason = this.cellularDeviceState.inhibitReason;
      if (inhibitReason === InhibitReason.kInstallingProfile ||
          inhibitReason === InhibitReason.kRefreshingProfileList ||
          inhibitReason === InhibitReason.kRequestingAvailableProfiles) {
        return false;
      }
    }
    return !this.shouldShowNetworkSublist_(
        this.eSimNetworks_, this.eSimPendingProfileItems_);
  }

  /**
   * Return true IFF there are no eSIM profiles installed, and the cellular
   * device is inhibited for any reason NOT related to changes to the eSIM
   * profile list or policy restricts the user from adding an eSIM profile.
   */
  private shouldShowNoEsimNetworksMessageWithoutLink_(): boolean {
    return this.shouldShowNoEsimNetworksMessage_() &&
        this.isInhibitedOrAffectedByPolicy_();
  }

  /**
   * Return true IFF there are no eSIM profiles installed, and the cellular
   * device is NOT inhibited for any reason related to changes to the eSIM
   * profile list and policy does NOT restrict the user from adding an eSIM
   * profile.
   */
  private shouldShowAddEsimMessageWithLink(): boolean {
    return this.shouldShowNoEsimNetworksMessage_() &&
        !this.isInhibitedOrAffectedByPolicy_();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [CellularNetworksListElement.is]: CellularNetworksListElement;
  }
}

customElements.define(
    CellularNetworksListElement.is, CellularNetworksListElement);