chromium/ash/webui/common/resources/cellular_setup/psim_flow_ui.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.

import './setup_loading_page.js';
import './provisioning_page.js';
import './final_page.js';
import '//resources/polymer/v3_0/iron-pages/iron-pages.js';

import {I18nMixin} from '//resources/ash/common/cr_elements/i18n_mixin.js';
import {assert, assertNotReached} from '//resources/js/assert.js';
import {ActivationDelegateReceiver, ActivationResult, CarrierPortalHandlerRemote, CarrierPortalStatus, CellularMetadata, CellularSetupInterface} from '//resources/mojo/chromeos/ash/services/cellular_setup/public/mojom/cellular_setup.mojom-webui.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {CellularSetupDelegate} from './cellular_setup_delegate.js';
import {ButtonState} from './cellular_types.js';
import {FinalPageElement} from './final_page.js';
import {getCellularSetupRemote} from './mojo_interface_provider.js';
import {ProvisioningPageElement} from './provisioning_page.js';
import {getTemplate} from './psim_flow_ui.html.js';
import {SetupLoadingPageElement} from './setup_loading_page.js';
import {SubflowMixin} from './subflow_mixin.js';

export enum PsimPageName {
  SIM_DETECT = 'simDetectPage',
  PROVISIONING = 'provisioningPage',
  FINAL = 'finalPage',
}

export enum PsimUiState {
  IDLE = 'idle',
  STARTING_ACTIVATION = 'starting-activation',
  WAITING_FOR_ACTIVATION_TO_START = 'waiting-for-activation-to-start',
  TIMEOUT_START_ACTIVATION = 'timeout-start-activation',
  FINAL_TIMEOUT_START_ACTIVATION = 'final-timeout-start-activation',
  WAITING_FOR_PORTAL_TO_LOAD = 'waiting-for-portal-to-load',
  TIMEOUT_PORTAL_LOAD = 'timeout-portal-load',
  WAITING_FOR_USER_PAYMENT = 'waiting-for-user-payment',
  WAITING_FOR_ACTIVATION_TO_FINISH = 'waiting-for-activation-to-finish',
  TIMEOUT_FINISH_ACTIVATION = 'timeout-finish-activation',
  ACTIVATION_SUCCESS = 'activation-success',
  ALREADY_ACTIVATED = 'already-activated',
  ACTIVATION_FAILURE = 'activation-failure',
}

// The reason that caused the user to exit the PSim Setup flow.
// These values are persisted to logs. Entries should not be renumbered
// and numeric values should never be reused.
export enum PsimSetupFlowResult {
  SUCCESS = 0,
  CANCELLED = 1,
  CANCELLED_NO_SIM = 2,
  CANCELLED_COLD_SIM_DEFER = 3,
  CANCELLED_CARRIER_PORTAL = 4,
  CANCELLED_PORTAL_ERROR = 5,
  CARRIER_PORTAL_TIMEOUT = 6,
  NETWORK_ERROR = 7,
}

/**
 * The time delta, in ms, for the timeout corresponding to |state|. If no
 * timeout is applicable for this state, null is returned.
 */
function getTimeoutMsForPsimUiState(state: PsimUiState): number|null {
  // In some cases, starting activation may require power-cycling the device's
  // modem, a process that can take several seconds.
  if (state === PsimUiState.STARTING_ACTIVATION) {
    return 10000;  // 10 seconds.
  }

  // The portal is a website served by the mobile carrier.
  if (state === PsimUiState.WAITING_FOR_PORTAL_TO_LOAD) {
    return 10000;  // 10 seconds.
  }

  // Finishing activation only requires sending a D-Bus message to Shill.
  if (state === PsimUiState.WAITING_FOR_ACTIVATION_TO_FINISH) {
    return 1000;  // 1 second.
  }

  // No other states require timeouts.
  return null;
}

/**
 * The maximum tries allowed to detect the SIM.
 */
const MAX_START_ACTIVATION_ATTEMPTS = 3;

export const PSIM_SETUP_RESULT_METRIC_NAME =
    'Network.Cellular.PSim.SetupFlowResult';

export const SUCCESSFUL_PSIM_SETUP_DURATION_METRIC_NAME =
    'Network.Cellular.PSim.CellularSetup.Success.Duration';

export const FAILED_PSIM_SETUP_DURATION_METRIC_NAME =
    'Network.Cellular.PSim.CellularSetup.Failure.Duration';

/**
 * Root element for the pSIM cellular setup flow. This element interacts with
 * the CellularSetup service to carry out the psim activation flow. It
 * contains navigation buttons and sub-pages corresponding to each step of the
 * flow.
 */
const PsimFlowUiElementBase = SubflowMixin(I18nMixin(PolymerElement));

export class PsimFlowUiElement extends PsimFlowUiElementBase {
  static get is() {
    return 'psim-flow-ui' as const;
  }

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

  static get properties() {
    return {
      delegate: Object,

      /**
       * Carrier name; used in dialog title to show the current carrier
       * name being setup
       */
      nameOfCarrierPendingSetup: {
        type: String,
        notify: true,
        computed: 'getCarrierText(' +
            'selectedPsimPageName_, cellularMetadata_.*)',
      },

      forwardButtonLabel: {
        type: String,
        notify: true,
      },

      state_: {
        type: String,
        value: PsimUiState.IDLE,
        observer: 'handlePsimUiStateChange_',
      },

      /**
       * Element name of the current selected sub-page.
       */
      selectedPsimPageName_: {
        type: String,
        value: PsimPageName.SIM_DETECT,
        notify: true,
      },

      /**
       * DOM Element for the current selected sub-page.
       */
      selectedPage_: Object,

      /**
       * Whether error state should be shown for the current page.
       */
      showError_: {type: Boolean, value: false},

      /**
       * Cellular metadata received via the onActivationStarted() callback. If
       * that callback has not occurred, this field is null.
       */
      cellularMetadata_: {
        type: Object,
        value: null,
      },

      /**
       * The current number of tries to detect the SIM.
       */
      startActivationAttempts_: {
        type: Number,
        value: 0,
      },
    };
  }

  delegate: CellularSetupDelegate;
  nameOfCarrierPendingSetup: string;
  forwardButtonLabel: string;
  private state_: PsimUiState;
  private selectedPsimPageName_: PsimPageName;
  private selectedPage_:
      SetupLoadingPageElement|ProvisioningPageElement|FinalPageElement;
  private showError_: boolean;
  private cellularMetadata_: CellularMetadata|null;
  private startActivationAttempts_: number;

  /**
   * Provides an interface to the CellularSetup Mojo service.
   */
  private cellularSetupRemote_: CellularSetupInterface|null = null;

  /**
   * Delegate responsible for routing activation started/finished events.
   */
  private activationDelegateReceiver_: ActivationDelegateReceiver|null = null;

  /**
   * The timeout ID corresponding to a timeout for the current state. If no
   * timeout is active, this value is null.
   */
  private currentTimeoutId_: number|null = null;

  /**
   * Handler used to communicate state updates back to the CellularSetup
   * service.
   */
  private carrierPortalHandler_: CarrierPortalHandlerRemote|null = null;

  /**
   * Whether there was a carrier portal error.
   */
  private didCarrierPortalResultFail_: boolean = false;

  /**
   * The function used to initiate a timer. Can be overwritten in tests.
   */
  private setTimeoutFunction_: Function = setTimeout.bind(window);

  /**
   * The time at which the PSim flow is attached.
   */
  private timeOnAttached_: Date|null = null;

  constructor() {
    super();

    this.cellularSetupRemote_ = getCellularSetupRemote();
  }

  override connectedCallback() {
    super.connectedCallback();

    this.timeOnAttached_ = new Date();
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    let resultCode = null;
    switch (this.state_) {
      case PsimUiState.IDLE:
      case PsimUiState.STARTING_ACTIVATION:
        resultCode = PsimSetupFlowResult.CANCELLED;
        break;
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_START:
        resultCode = PsimSetupFlowResult.CANCELLED_COLD_SIM_DEFER;
        break;
      case PsimUiState.TIMEOUT_START_ACTIVATION:
      case PsimUiState.FINAL_TIMEOUT_START_ACTIVATION:
        resultCode = PsimSetupFlowResult.CANCELLED_NO_SIM;
        break;
      case PsimUiState.WAITING_FOR_PORTAL_TO_LOAD:
        resultCode = PsimSetupFlowResult.CANCELLED;
        break;
      case PsimUiState.TIMEOUT_PORTAL_LOAD:
        resultCode = PsimSetupFlowResult.CARRIER_PORTAL_TIMEOUT;
        break;
      case PsimUiState.WAITING_FOR_USER_PAYMENT:
        resultCode = PsimSetupFlowResult.CANCELLED_CARRIER_PORTAL;
        break;
      case PsimUiState.ACTIVATION_SUCCESS:
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_FINISH:
      case PsimUiState.TIMEOUT_FINISH_ACTIVATION:
      case PsimUiState.ALREADY_ACTIVATED:
        resultCode = PsimSetupFlowResult.SUCCESS;
        break;
      case PsimUiState.ACTIVATION_FAILURE:
        resultCode = this.didCarrierPortalResultFail_ ?
            PsimSetupFlowResult.CANCELLED_PORTAL_ERROR :
            PsimSetupFlowResult.NETWORK_ERROR;
        break;
      default:
        assertNotReached();
    }

    assert(resultCode !== null);
    chrome.metricsPrivate.recordEnumerationValue(
        PSIM_SETUP_RESULT_METRIC_NAME, resultCode,
        Object.keys(PsimSetupFlowResult).length);

    const elapsedTimeMs = Date.now() - this.timeOnAttached_!.getTime();
    if (resultCode === PsimSetupFlowResult.SUCCESS) {
      chrome.metricsPrivate.recordLongTime(
          SUCCESSFUL_PSIM_SETUP_DURATION_METRIC_NAME, elapsedTimeMs);
      return;
    }

    chrome.metricsPrivate.recordLongTime(
        FAILED_PSIM_SETUP_DURATION_METRIC_NAME, elapsedTimeMs);
  }

  /**
   * Overrides ActivationDelegateInterface.
   */
  onActivationStarted(metadata: CellularMetadata): void {
    this.clearTimer_();
    this.cellularMetadata_ = metadata;
    this.state_ = PsimUiState.WAITING_FOR_PORTAL_TO_LOAD;
  }

  override initSubflow(): void {
    this.state_ = PsimUiState.STARTING_ACTIVATION;
    this.startActivationAttempts_ = 0;
    this.updateButtonBarState_();
    this.dispatchEvent(new CustomEvent(
      'focus-default-button', {bubbles: true, composed: true}));
  }

  override navigateForward(): void {
    switch (this.state_) {
      case PsimUiState.WAITING_FOR_PORTAL_TO_LOAD:
      case PsimUiState.TIMEOUT_PORTAL_LOAD:
      case PsimUiState.WAITING_FOR_USER_PAYMENT:
      case PsimUiState.ACTIVATION_SUCCESS:
        this.state_ = PsimUiState.WAITING_FOR_ACTIVATION_TO_FINISH;
        break;
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_FINISH:
      case PsimUiState.TIMEOUT_FINISH_ACTIVATION:
      case PsimUiState.FINAL_TIMEOUT_START_ACTIVATION:
      case PsimUiState.ALREADY_ACTIVATED:
      case PsimUiState.ACTIVATION_FAILURE:
        this.dispatchEvent(new CustomEvent(
            'exit-cellular-setup', {bubbles: true, composed: true}));
        break;
      case PsimUiState.TIMEOUT_START_ACTIVATION:
        this.state_ = PsimUiState.STARTING_ACTIVATION;
        break;
      default:
        assertNotReached();
    }
  }

  /**
   * Sets the function used to initiate a timer.
   */
  setTimerFunctionForTest(timerFunction: Function): void {
    this.setTimeoutFunction_ = timerFunction;
  }

  getSelectedPsimPageNameForTest(): PsimPageName {
    return this.selectedPsimPageName_;
  }

  getCurrentTimeoutIdForTest(): number|null {
    return this.currentTimeoutId_;
  }

  setCurrentPsimUiStateForTest(state: PsimUiState): void {
    this.state_ = state;
  }

  getCurrentPsimUiStateForTest(): PsimUiState {
    return this.state_;
  }

  private updateButtonBarState_(): void {
    let buttonState;
    switch (this.state_) {
      case PsimUiState.IDLE:
      case PsimUiState.STARTING_ACTIVATION:
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_START:
      case PsimUiState.WAITING_FOR_PORTAL_TO_LOAD:
      case PsimUiState.TIMEOUT_PORTAL_LOAD:
      case PsimUiState.WAITING_FOR_USER_PAYMENT:
        this.forwardButtonLabel = this.i18n('next');
        buttonState = {
          cancel: ButtonState.ENABLED,
          forward: ButtonState.DISABLED,
        };
        break;
      case PsimUiState.TIMEOUT_START_ACTIVATION:
        this.forwardButtonLabel = this.i18n('tryAgain');
        buttonState = {
          cancel: ButtonState.ENABLED,
          forward: ButtonState.ENABLED,
        };
        break;
      case PsimUiState.ACTIVATION_SUCCESS:
        this.forwardButtonLabel = this.i18n('next');
        buttonState = {
          cancel: ButtonState.ENABLED,
          forward: ButtonState.ENABLED,
        };
        break;
      case PsimUiState.ALREADY_ACTIVATED:
      case PsimUiState.ACTIVATION_FAILURE:
      case PsimUiState.FINAL_TIMEOUT_START_ACTIVATION:
        this.forwardButtonLabel = this.i18n('done');
        buttonState = {
          cancel: ButtonState.ENABLED,
          forward: ButtonState.ENABLED,
        };
        break;
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_FINISH:
      case PsimUiState.TIMEOUT_FINISH_ACTIVATION:
        this.forwardButtonLabel = this.i18n('done');
        buttonState = {
          cancel: ButtonState.HIDDEN,
          forward: ButtonState.ENABLED,
        };
        break;
      default:
        assertNotReached();
    }
    this.set('buttonState', buttonState);
  }

  /**
   * Overrides ActivationDelegateInterface.
   */
  onActivationFinished(result: ActivationResult): void {
    this.closeActivationConnection_();

    switch (result) {
      case ActivationResult.kSuccessfullyStartedActivation:
        this.state_ = PsimUiState.ACTIVATION_SUCCESS;
        break;
      case ActivationResult.kAlreadyActivated:
        this.state_ = PsimUiState.ALREADY_ACTIVATED;
        break;
      case ActivationResult.kFailedToActivate:
        this.state_ = PsimUiState.ACTIVATION_FAILURE;
        break;
      default:
        assertNotReached();
    }
  }

  private getCarrierText(): string {
    if (this.selectedPsimPageName_ === PsimPageName.PROVISIONING &&
        this.cellularMetadata_) {
      return this.cellularMetadata_.carrier;
    }
    return '';
  }

  private updateShowError_(): void {
    switch (this.state_) {
      case PsimUiState.TIMEOUT_PORTAL_LOAD:
      case PsimUiState.TIMEOUT_FINISH_ACTIVATION:
      case PsimUiState.ACTIVATION_FAILURE:
        this.showError_ = true;
        return;
      default:
        this.showError_ = false;
        return;
    }
  }

  private updateSelectedPage_(): void {
    switch (this.state_) {
      case PsimUiState.IDLE:
      case PsimUiState.STARTING_ACTIVATION:
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_START:
      case PsimUiState.TIMEOUT_START_ACTIVATION:
      case PsimUiState.FINAL_TIMEOUT_START_ACTIVATION:
        this.selectedPsimPageName_ = PsimPageName.SIM_DETECT;
        return;
      case PsimUiState.WAITING_FOR_PORTAL_TO_LOAD:
      case PsimUiState.TIMEOUT_PORTAL_LOAD:
      case PsimUiState.WAITING_FOR_USER_PAYMENT:
      case PsimUiState.ACTIVATION_SUCCESS:
        this.selectedPsimPageName_ = PsimPageName.PROVISIONING;
        return;
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_FINISH:
      case PsimUiState.TIMEOUT_FINISH_ACTIVATION:
      case PsimUiState.ALREADY_ACTIVATED:
      case PsimUiState.ACTIVATION_FAILURE:
        this.selectedPsimPageName_ = PsimPageName.FINAL;
        return;
      default:
        assertNotReached();
    }
  }

  private handlePsimUiStateChange_(): void {
    this.updateShowError_();
    this.updateSelectedPage_();

    // Since the state has changed, the previous state did not time out, so
    // clear any active timeout.
    this.clearTimer_();

    // If the new state has an associated timeout, set it.
    const timeoutMs = getTimeoutMsForPsimUiState(this.state_);
    if (timeoutMs !== null) {
      this.currentTimeoutId_ =
          this.setTimeoutFunction_(this.onTimeout_.bind(this), timeoutMs);
    }

    if (this.state_ === PsimUiState.STARTING_ACTIVATION) {
      this.startActivation_();
    }

    this.updateButtonBarState_();
  }

  private onTimeout_(): void {
    // The activation attempt failed, so close the connection to the service.
    this.closeActivationConnection_();

    switch (this.state_) {
      case PsimUiState.STARTING_ACTIVATION:
        this.startActivationAttempts_++;
        if (this.startActivationAttempts_ < MAX_START_ACTIVATION_ATTEMPTS) {
          this.state_ = PsimUiState.TIMEOUT_START_ACTIVATION;
        } else {
          this.state_ = PsimUiState.FINAL_TIMEOUT_START_ACTIVATION;
        }
        return;
      case PsimUiState.WAITING_FOR_PORTAL_TO_LOAD:
        this.state_ = PsimUiState.TIMEOUT_PORTAL_LOAD;
        return;
      case PsimUiState.WAITING_FOR_ACTIVATION_TO_FINISH:
        this.state_ = PsimUiState.TIMEOUT_FINISH_ACTIVATION;
        return;
      default:
        // Only the above states are expected to time out.
        assertNotReached();
    }
  }

  private startActivation_() {
    assert(!this.activationDelegateReceiver_);
    this.activationDelegateReceiver_ = new ActivationDelegateReceiver(
        (this));

    this.cellularSetupRemote_!
        .startActivation(
            this.activationDelegateReceiver_.$.bindNewPipeAndPassRemote())
        .then(
            (params) => {
              this.carrierPortalHandler_ = params.observer;
            });
  }

  private closeActivationConnection_(): void {
    assert(!!this.activationDelegateReceiver_);
    this.activationDelegateReceiver_.$.close();
    this.activationDelegateReceiver_ = null;
    this.carrierPortalHandler_ = null;
    this.cellularMetadata_ = null;
  }

  private clearTimer_(): void {
    if (this.currentTimeoutId_) {
      clearTimeout(this.currentTimeoutId_);
    }
    this.currentTimeoutId_ = null;
  }

  private onCarrierPortalLoaded_(): void {
    this.state_ = PsimUiState.WAITING_FOR_USER_PAYMENT;
    this.carrierPortalHandler_!.onCarrierPortalStatusChange(
        CarrierPortalStatus.kPortalLoadedWithoutPaidUser);
  }

  private onCarrierPortalResult_(event: CustomEvent<boolean>): void {
    const success = event.detail;
    this.didCarrierPortalResultFail_ = !success;
    this.state_ = success ? PsimUiState.ACTIVATION_SUCCESS :
                            PsimUiState.ACTIVATION_FAILURE;
  }

  private getLoadingMessage_(): string {
    if (this.state_ === PsimUiState.TIMEOUT_START_ACTIVATION) {
      return this.i18n('simDetectPageErrorMessage');
    } else if (this.state_ === PsimUiState.FINAL_TIMEOUT_START_ACTIVATION) {
      return this.i18n('simDetectPageFinalErrorMessage');
    }
    return this.i18n('establishNetworkConnectionMessage');
  }

  private isSimDetectError_(): boolean {
    return this.state_ === PsimUiState.TIMEOUT_START_ACTIVATION ||
        this.state_ === PsimUiState.FINAL_TIMEOUT_START_ACTIVATION;
  }

  private getLoadingTitle_(): string {
    if (this.delegate.shouldShowPageTitle() && this.isSimDetectError_()) {
      return this.i18n('simDetectPageErrorTitle');
    }
    return '';
  }
}

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

customElements.define(PsimFlowUiElement.is, PsimFlowUiElement);