chromium/ash/webui/firmware_update_ui/resources/firmware_update_dialog.ts

// Copyright 2021 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/cr_dialog/cr_dialog.js';
import 'chrome://resources/mojo/mojo/public/mojom/base/big_buffer.mojom-webui.js';
import 'chrome://resources/mojo/mojo/public/mojom/base/file_path.mojom-webui.js';
import 'chrome://resources/mojo/mojo/public/mojom/base/string16.mojom-webui.js';
import 'chrome://resources/polymer/v3_0/paper-progress/paper-progress.js';
import './firmware_shared.css.js';
import './firmware_shared_fonts.css.js';
import './firmware_update.mojom-webui.js';
import './strings.m.js';

import {I18nMixin, I18nMixinInterface} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {mojoString16ToString} from 'chrome://resources/js/mojo_type_util.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {DeviceRequest, DeviceRequestId, DeviceRequestKind, DeviceRequestObserverInterface, DeviceRequestObserverReceiver, FirmwareUpdate, InstallationProgress, InstallControllerRemote, UpdateProgressObserverInterface, UpdateProgressObserverReceiver, UpdateState} from './firmware_update.mojom-webui.js';
import {getTemplate} from './firmware_update_dialog.html.js';
import {DialogContent, OpenUpdateDialogEventDetail} from './firmware_update_types.js';
import {isAppV2Enabled} from './firmware_update_utils.js';
import {getUpdateProvider} from './mojo_interface_provider.js';

const initialDialogContent: DialogContent = {
  title: '',
  body: '',
  footer: '',
};

const initialInstallationProgress: InstallationProgress = {
  percentage: 0,
  state: UpdateState.kIdle,
};

/**
 * @fileoverview
 * 'firmware-update-dialog' displays information related to a firmware update.
 */

const FirmwareUpdateDialogElementBase =
    I18nMixin(PolymerElement) as {new (): PolymerElement & I18nMixinInterface};

export class FirmwareUpdateDialogElement extends FirmwareUpdateDialogElementBase
    implements UpdateProgressObserverInterface, DeviceRequestObserverInterface {
  static get is() {
    return 'firmware-update-dialog' as const;
  }

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

  static get properties() {
    return {
      update: {
        type: Object,
      },

      installationProgress: {
        type: Object,
        value: initialInstallationProgress,
        observer:
            FirmwareUpdateDialogElement.prototype.installationProgressChanged,
      },

      isInitiallyInflight: {
        value: false,
      },

      dialogContent: {
        type: Object,
        value: initialDialogContent,
        computed: 'computeDialogContent(installationProgress.*,' +
            'isInitiallyInflight, lastDeviceRequestId)',
      },

      updateIsDone: {
        type: Boolean,
        value: false,
        computed: 'isUpdateDone(installationProgress.state)',
        reflectToAttribute: true,
      },

      /**
       * This property is used to keep track of the ID of the last-received
       * DeviceRequest. If this property is not null, it means there is a
       * pending request. If the property is null, it means there are no pending
       * requests.
       */
      lastDeviceRequestId: {
        type: Object,
        value: null,
      },
    };
  }

  update: FirmwareUpdate|null = null;
  installationProgress: InstallationProgress;
  private isInitiallyInflight = false;
  private lastDeviceRequestId: DeviceRequestId|null = null;
  dialogContent = initialDialogContent;
  private updateProvider = getUpdateProvider();
  private installController: InstallControllerRemote|null = null;
  private updateProgressObserverReceiver: UpdateProgressObserverReceiver|null =
      null;
  private deviceRequestObserverReceiver: DeviceRequestObserverReceiver|null =
      null;
  private inactiveDialogStates: UpdateState[] =
      [UpdateState.kUnknown, UpdateState.kIdle];


  override connectedCallback() {
    super.connectedCallback();

    // When v2 of the app is not enabled, treat "kWaitingForUser" as an inactive
    // state. This gracefully handles an unexpected edge case where fwupd sends
    // a kWaitingForUser status even though the v2 flag is disabled (which
    // shouldn't normally happen).
    if (!isAppV2Enabled()) {
      this.inactiveDialogStates.push(UpdateState.kWaitingForUser);
    }

    window.addEventListener(
        'open-update-dialog',
        (e) => this.onOpenUpdateDialog(
            e as CustomEvent<OpenUpdateDialogEventDetail>));
  }

  /** Implements DeviceRequestObserver.onDeviceRequest */
  onDeviceRequest(request: DeviceRequest): void {
    // OnDeviceRequest should only be triggered when the v2 flag is enabled.
    assert(isAppV2Enabled());

    if (request.kind !== DeviceRequestKind.kImmediate) {
      // Ignore non-immediate requests.
      return;
    }

    this.lastDeviceRequestId = request.id;
  }

  /** Implements UpdateProgressObserver.onStatusChanged */
  onStatusChanged(update: InstallationProgress): void {
    // If the update switched *away* from kWaitingForUser, hide any requests.
    // This can happen as part of the normal flow (i.e. the request was executed
    // by the user) or as part of an error flow (e.g. the instruction timed out,
    // or some other error occurred). In either case, we want to reset
    // lastDeviceRequestId so that the app knows the hide the request.
    if (isAppV2Enabled() && update.state !== UpdateState.kWaitingForUser &&
        this.installationProgress.state === UpdateState.kWaitingForUser) {
      this.lastDeviceRequestId = null;
    }
    if (update.state === UpdateState.kSuccess ||
        update.state === UpdateState.kFailed) {
      // Install is completed, reset inflight state.
      this.isInitiallyInflight = false;
    }
    this.installationProgress = update;
  }

  protected installationProgressChanged(
      prevProgress: FirmwareUpdateDialogElement['installationProgress'],
      currProgress: FirmwareUpdateDialogElement['installationProgress']): void {
    if (!currProgress || prevProgress.state == currProgress.state) {
      return;
    }
    // Focus the dialog title if the update state has changed.
    assert(this.shadowRoot);
    const dialogTitle =
        this.shadowRoot.querySelector<HTMLElement>('#updateDialogTitle');
    if (dialogTitle) {
      dialogTitle.focus();
    }
  }

  protected closeDialog(): void {
    this.isInitiallyInflight = false;
    // Resetting |installationProgress| triggers a call to
    // |shouldShowUpdateDialog|.
    this.installationProgress = initialInstallationProgress;
    this.update = null;
  }

  protected async prepareForUpdate(): Promise<void> {
    assert(this.update);
    const response =
        await this.updateProvider.prepareForUpdate(this.update.deviceId);
    if (!response.controller) {
      // TODO(michaelcheco): Handle |StartInstall| failed case.
      return;
    }
    this.installController = response.controller;
    this.bindReceiverAndMaybeStartUpdate();
  }

  protected bindReceiverAndMaybeStartUpdate(): void {
    this.updateProgressObserverReceiver =
        new UpdateProgressObserverReceiver(this);
    assert(this.installController);
    this.installController.addUpdateProgressObserver(
        this.updateProgressObserverReceiver.$.bindNewPipeAndPassRemote());

    // Listen for device requests if v2 of the app is enabled.
    if (isAppV2Enabled()) {
      this.deviceRequestObserverReceiver =
          new DeviceRequestObserverReceiver(this);
      this.installController.addDeviceRequestObserver(
          this.deviceRequestObserverReceiver.$.bindNewPipeAndPassRemote());
    }

    // Only start new updates, inflight updates will be observed instead.
    if (!this.isInitiallyInflight) {
      assert(this.update);
      this.installController.beginUpdate(
          this.update.deviceId, this.update.filepath);
    }
  }

  protected shouldShowUpdateDialog(): boolean {
    if (!this.update) {
      return false;
    }

    // Handles the case in which an update is in progress on app load, but has
    // yet to receive an progress update callback.
    if (this.isInitiallyInflight) {
      return true;
    }

    const activeDialogStates: UpdateState[] = [
      UpdateState.kUpdating,
      UpdateState.kRestarting,
      UpdateState.kFailed,
      UpdateState.kSuccess,
    ];

    if (isAppV2Enabled()) {
      activeDialogStates.push(UpdateState.kWaitingForUser);
    }

    // Show dialog is there is an update in progress.
    return activeDialogStates.includes(this.installationProgress.state) ||
        this.installationProgress.percentage > 0;
  }

  protected computePercentageValue(): number {
    if (this.installationProgress?.percentage) {
      return this.installationProgress.percentage;
    }
    return 0;
  }

  protected isUpdateInProgress(): boolean {
    if (this.inactiveDialogStates.includes(this.installationProgress.state)) {
      return this.installationProgress.percentage > 0;
    }

    return this.installationProgress.state === UpdateState.kUpdating;
  }

  protected isDeviceRestarting(): boolean {
    return this.installationProgress.state === UpdateState.kRestarting;
  }

  protected shouldShowProgressBar(): boolean {
    const showProgressBar = this.isUpdateInProgress() ||
        this.isDeviceRestarting() || this.isWaitingForUserAction() ||
        this.isInitiallyInflight;
    assert(this.shadowRoot);
    const progressIsActiveEl = this.shadowRoot.activeElement ==
        this.shadowRoot.querySelector('#progress');
    // Move focus to the dialog title if the progress label is currently
    // active and set to be hidden. This case is reached when the dialog state
    // moves from restarting to completed.
    const dialogTitle =
        this.shadowRoot.querySelector<HTMLElement>('#updateDialogTitle');
    if (progressIsActiveEl && !showProgressBar && dialogTitle) {
      dialogTitle.focus();
    }
    return showProgressBar;
  }

  protected isUpdateDone(): boolean {
    return this.installationProgress.state === UpdateState.kSuccess ||
        this.installationProgress.state === UpdateState.kFailed;
  }

  createRequestDialogContent(): DialogContent {
    assert(this.update);
    const {deviceName} = this.update;
    const {percentage} = this.installationProgress;
    assert(this.lastDeviceRequestId !== null);

    const deviceNameString: string = mojoString16ToString(deviceName);

    return {
      title: this.i18n('updating', deviceNameString),
      body: this.getI18nStringForDeviceRequestId(
          this.lastDeviceRequestId, deviceNameString),
      footer: this.i18n('waitingFooterText', percentage),
    };
  }

  createDialogContentObj(state: UpdateState): DialogContent {
    assert(this.update);
    const {deviceName, deviceVersion} = this.update;
    const {percentage} = this.installationProgress;

    const dialogContent = new Map<UpdateState, DialogContent>([
      [
        UpdateState.kUpdating,
        {
          title: this.i18n('updating', mojoString16ToString(deviceName)),
          body: this.i18n('updatingInfo'),
          footer: this.i18n('installing', percentage),
        },
      ],
      [
        UpdateState.kRestarting,
        {
          title: this.i18n(
              'restartingTitleText', mojoString16ToString(deviceName)),
          body: this.i18n('restartingBodyText'),
          footer: this.i18n('restartingFooterText'),
        },
      ],
      [
        UpdateState.kFailed,
        {
          title: this.i18n(
              'updateFailedTitleText', mojoString16ToString(deviceName)),
          body: this.i18n('updateFailedBodyText'),
          footer: '',
        },
      ],
      [
        UpdateState.kSuccess,
        {
          title: this.i18n('deviceUpToDate', mojoString16ToString(deviceName)),
          body: this.i18n(
              'hasBeenUpdated', mojoString16ToString(deviceName),
              deviceVersion),
          footer: '',
        },
      ],
    ]);

    assert(dialogContent.has(state));
    return dialogContent.get(state) as DialogContent;
  }

  computeDialogContent(): DialogContent {
    // No update in progress.
    if (!this.isInitiallyInflight && !this.update) {
      return initialDialogContent;
    }

    if (this.inactiveDialogStates.includes(this.installationProgress.state) ||
        this.isDeviceRestarting()) {
      return this.createDialogContentObj(UpdateState.kRestarting);
    }

    // Regular case: Update is in progress, started from the same instance of
    // which the app launched.
    // Edge case: App launch with an update in progress, but no progress
    // callback has been called yet.
    if (this.isInitiallyInflight || this.isUpdateInProgress()) {
      return this.createDialogContentObj(UpdateState.kUpdating);
    }

    if (isAppV2Enabled() &&
        this.installationProgress.state === UpdateState.kWaitingForUser) {
      if (this.lastDeviceRequestId === null) {
        // Show normal update flow until onDeviceRequest is called.
        return this.createDialogContentObj(UpdateState.kUpdating);
      } else {
        return this.createRequestDialogContent();
      }
    }

    if (this.isUpdateDone()) {
      return this.createDialogContentObj(this.installationProgress.state);
    }
    return initialDialogContent;
  }

  protected isInIndeterminateState(): boolean {
    if (this.installationProgress) {
      return this.inactiveDialogStates.includes(
                 this.installationProgress.state) ||
          this.isDeviceRestarting();
    }

    return false;
  }

  protected isProgressBarDisabled(): boolean {
    return this.isWaitingForUserAction();
  }

  protected computeButtonText(): string {
    if (!this.isUpdateDone()) {
      return '';
    }

    return this.installationProgress.state === UpdateState.kSuccess ?
        this.i18n('doneButton') :
        this.i18n('okButton');
  }

  protected isDialogOpen(): boolean {
    assert(this.shadowRoot);
    return !!this.shadowRoot.querySelector('#updateDialog');
  }

  /** Event callback for 'open-update-dialog'. */
  private onOpenUpdateDialog(e: CustomEvent<OpenUpdateDialogEventDetail>):
      void {
    this.update = e.detail.update;
    this.isInitiallyInflight = e.detail.inflight;
    this.prepareForUpdate();
  }

  setIsInitiallyInflightForTesting(isInitiallyInflight: boolean): void {
    this.isInitiallyInflight = isInitiallyInflight;
  }

  private isWaitingForUserAction(): boolean {
    return isAppV2Enabled() && this.lastDeviceRequestId !== null &&
        this.installationProgress.state === UpdateState.kWaitingForUser;
  }

  private getDialogBodyAriaLive(): string {
    // Use assertive aria-live value to ensure user requests are announced
    // before they time out.
    return this.isWaitingForUserAction() ? 'assertive' : '';
  }

  private getStringIdForDeviceRequestId(deviceRequestId: DeviceRequestId):
      string {
    switch (deviceRequestId) {
      case (DeviceRequestId.kDoNotPowerOff):
        return 'requestIdDoNotPowerOff';
      case (DeviceRequestId.kReplugInstall):
        return 'requestIdReplugInstall';
      case (DeviceRequestId.kInsertUSBCable):
        return 'requestIdInsertUsbCable';
      case (DeviceRequestId.kRemoveUSBCable):
        return 'requestIdRemoveUsbCable';
      case (DeviceRequestId.kPressUnlock):
        return 'requestIdPressUnlock';
      case (DeviceRequestId.kRemoveReplug):
        return 'requestIdRemoveReplug';
      case (DeviceRequestId.kReplugPower):
        return 'requestIdReplugPower';
    }
  }

  private getI18nStringForDeviceRequestId(
      deviceRequestId: DeviceRequestId, deviceName: string): string {
    const requestStringId = this.getStringIdForDeviceRequestId(deviceRequestId);

    // DoNotPowerOff request does not use the device name.
    if (deviceRequestId == DeviceRequestId.kDoNotPowerOff) {
      return this.i18n(requestStringId);
    }

    return this.i18n(requestStringId, deviceName);
  }
}

declare global {
  interface HTMLElementEventMap {
    'open-update-dialog': CustomEvent<OpenUpdateDialogEventDetail>;
  }

  interface HTMLElementTagNameMap {
    [FirmwareUpdateDialogElement.is]: FirmwareUpdateDialogElement;
  }
}

customElements.define(
    FirmwareUpdateDialogElement.is, FirmwareUpdateDialogElement);