chromium/chrome/browser/resources/ash/settings/os_printing_page/cups_printers.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 'settings-cups-printers' is a component for showing CUPS
 * Printer settings subpage (chrome://settings/cupsPrinters). It is used to
 * set up legacy & non-CloudPrint printers on ChromeOS by leveraging CUPS (the
 * unix printing system) and the many open source drivers built for CUPS.
 */

// TODO(xdai): Rename it to 'settings-cups-printers-page'.
import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_toast/cr_toast.js';
import 'chrome://resources/ash/common/cr_elements/policy/cr_policy_pref_indicator.js';
import 'chrome://resources/js/action_link.js';
import 'chrome://resources/ash/common/cr_elements/action_link.css.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/ash/common/cr_elements/localized_link/localized_link.js';
import '../icons.html.js';
import './cups_edit_printer_dialog.js';
import './cups_enterprise_printers.js';
import './cups_printer_shared.css.js';
import './cups_printer_types.js';
import './cups_printers_browser_proxy.js';
import './cups_printers_entry.js';
import './cups_printers_entry_manager.js';
import './cups_saved_printers.js';
import './cups_settings_add_printer_dialog.js';

import {MojoInterfaceProviderImpl} from 'chrome://resources/ash/common/network/mojo_interface_provider.js';
import {NetworkListenerBehavior, NetworkListenerBehaviorInterface} from 'chrome://resources/ash/common/network/network_listener_behavior.js';
import {CrIconButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import {CrToastElement} from 'chrome://resources/ash/common/cr_elements/cr_toast/cr_toast.js';
import {WebUiListenerMixin, WebUiListenerMixinInterface} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {addWebUiListener, removeWebUiListener, WebUiListener} from 'chrome://resources/js/cr.js';
import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {CrosNetworkConfigInterface, FilterType, NetworkStateProperties, NO_LIMIT} from 'chrome://resources/mojo/chromeos/services/network_config/public/mojom/cros_network_config.mojom-webui.js';
import {ConnectionStateType, NetworkType} from 'chrome://resources/mojo/chromeos/services/network_config/public/mojom/network_types.mojom-webui.js';
import {afterNextRender, mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {DeepLinkingMixin, DeepLinkingMixinInterface} from '../common/deep_linking_mixin.js';
import {isRevampWayfindingEnabled} from '../common/load_time_booleans.js';
import {RouteObserverMixin, RouteObserverMixinInterface} from '../common/route_observer_mixin.js';
import {Constructor} from '../common/types.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, routes} from '../router.js';

import {PrinterListEntry, PrinterType} from './cups_printer_types.js';
import {getTemplate} from './cups_printers.html.js';
import {CupsPrinterInfo, CupsPrintersBrowserProxy, CupsPrintersBrowserProxyImpl, CupsPrintersList, PrinterSetupResult} from './cups_printers_browser_proxy.js';
import {CupsPrintersEntryManager} from './cups_printers_entry_manager.js';
import {SettingsCupsAddPrinterDialogElement} from './cups_settings_add_printer_dialog.js';

/**
 * Enumeration of the user actions that can be taken on the Printer settings
 * page.
 * This enum is tied directly to a UMA enum defined in
 * //tools/metrics/histograms/enums.xml, and should always reflect it (do not
 * change one without changing the other).
 * These values are persisted to logs. Entries should not be renumbered and
 * numeric values should never be reused.
 * @enum {number}
 */
export enum PrinterSettingsUserAction {
  ADD_PRINTER_MANUALLY = 0,
  SAVE_PRINTER = 1,
  EDIT_PRINTER = 2,
  REMOVE_PRINTER = 3,
  CLICK_HELP_LINK = 4,
}

export function recordPrinterSettingsUserAction(
    userAction: PrinterSettingsUserAction): void {
  chrome.metricsPrivate.recordEnumerationValue(
      'Printing.CUPS.SettingsUserAction', userAction,
      Object.keys(PrinterSettingsUserAction).length);
}

const SettingsCupsPrintersElementBase =
    mixinBehaviors(
        [
          NetworkListenerBehavior,
        ],
        DeepLinkingMixin(
            RouteObserverMixin(WebUiListenerMixin(PolymerElement)))) as
    Constructor<PolymerElement&WebUiListenerMixinInterface&
                RouteObserverMixinInterface&DeepLinkingMixinInterface&
                NetworkListenerBehaviorInterface>;

export interface SettingsCupsPrintersElement {
  $: {
    errorToast: CrToastElement,
    printServerErrorToast: CrToastElement,
    addPrinterDialog: SettingsCupsAddPrinterDialogElement,
  };
}

export class SettingsCupsPrintersElement extends
    SettingsCupsPrintersElementBase {
  static get is() {
    return 'settings-cups-printers';
  }

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

  static get properties() {
    return {
      printers: {
        type: Array,
        notify: true,
      },

      prefs: Object,

      activePrinter: {
        type: Object,
        notify: true,
      },

      onPrintersChangedListener_: {
        type: Object,
        value: null,
      },

      onEnterprisePrintersChangedListener_: {
        type: Object,
        value: null,
      },

      searchTerm: {
        type: String,
      },

      /**
       * This is also used as an attribute for css styling.
       */
      hasActiveNetworkConnection: {
        type: Boolean,
        reflectToAttribute: true,
      },

      savedPrinters_: {
        type: Array,
        value: () => [],
      },

      enterprisePrinters_: {
        type: Array,
        value: () => [],
      },

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

      showCupsEditPrinterDialog_: Boolean,

      addPrinterResultText_: String,

      nearbyPrintersAriaLabel_: {
        type: String,
        computed: 'getNearbyPrintersAriaLabel_(nearbyPrinterCount_)',
      },

      savedPrintersAriaLabel_: {
        type: String,
        computed: 'getSavedPrintersAriaLabel_(savedPrinterCount_)',
      },

      enterprisePrintersAriaLabel_: {
        type: String,
        computed: 'getEnterprisePrintersAriaLabel_(enterprisePrinterCount_)',
      },

      nearbyPrinterCount_: {
        type: Number,
        value: 0,
      },

      savedPrinterCount_: {
        type: Number,
        value: 0,
      },

      enterprisePrinterCount_: {
        type: Number,
        value: 0,
      },

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

      /**
       * Indicates whether the nearby printers section is expanded.
       * @private {boolean}
       */
      nearbyPrintersExpanded_: {
        type: Boolean,
        value: true,
      },

      /**
       * Indicates whether the nearby printers section is empty.
       * @private {boolean}
       */
      nearbyPrintersEmpty_: {
        type: Boolean,
        computed: 'computeNearbyPrintersEmpty_(nearbyPrinterCount_)',
        reflectToAttribute: true,
      },

      isRevampWayfindingEnabled_: {
        type: Boolean,
        value: () => {
          return isRevampWayfindingEnabled();
        },
      },
    };
  }

  activePrinter: CupsPrinterInfo;
  prefs: Object;
  printers: CupsPrinterInfo[];
  searchTerm: string;

  private addPrintServerResultText_: string;
  private addPrinterResultText_: string;
  private attemptedLoadingPrinters_: boolean;
  private browserProxy_: CupsPrintersBrowserProxy;
  private enterprisePrinterCount_: number;
  private enterprisePrintersAriaLabel_: string;
  private enterprisePrinters_: PrinterListEntry[];
  private entryManager_: CupsPrintersEntryManager;
  private hasActiveNetworkConnection: boolean;
  private isRevampWayfindingEnabled_: boolean;
  private nearbyPrinterCount_: number;
  private nearbyPrintersAriaLabel_: string;
  private networkConfig_: CrosNetworkConfigInterface;
  private onEnterprisePrintersChangedListener_: WebUiListener;
  private onPrintersChangedListener_: WebUiListener|null;
  private savedPrinterCount_: number;
  private savedPrintersAriaLabel_: string;
  private savedPrinters_: PrinterListEntry[];
  private showCupsEditPrinterDialog_: boolean;
  private nearbyPrintersExpanded_: boolean;
  private nearbyPrintersEmpty_: boolean;

  constructor() {
    super();

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


    this.entryManager_ = CupsPrintersEntryManager.getInstance();

    this.addPrintServerResultText_ = '';

    this.browserProxy_ = CupsPrintersBrowserProxyImpl.getInstance();

    // This request is made in the constructor to fetch the # of saved
    // printers for determining whether the nearby printers section should
    // start open or closed.
    this.browserProxy_.getCupsSavedPrintersList().then(
        savedPrinters => this.nearbyPrintersExpanded_ =
            savedPrinters.printerList.length === 0);
  }

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

    this.networkConfig_
        .getNetworkStateList({
          filter: FilterType.kActive,
          networkType: NetworkType.kAll,
          limit: NO_LIMIT,
        })
        .then((responseParams: {result: NetworkStateProperties[]}) => {
          this.onActiveNetworksChanged(responseParams.result);
        });
  }

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

    this.updateCupsPrintersList_();

    this.addEventListener(
        'edit-cups-printer-details', this.onShowCupsEditPrinterDialog_);
    this.addEventListener(
        'show-cups-printer-toast',
        (event: CustomEvent<
            {resultCode: PrinterSetupResult, printerName: string}>) => {
          this.openResultToast_(event);
        });
    this.addEventListener(
        'add-print-server-and-show-toast',
        (event: CustomEvent<{printers: CupsPrintersList}>) => {
          this.addPrintServerAndShowResultToast_((event));
        });
    this.addEventListener(
        'open-manufacturer-model-dialog-for-specified-printer',
        (event: CustomEvent<{item: CupsPrinterInfo}>) => {
          this.openManufacturerModelDialogForSpecifiedPrinter_(event);
        });
  }

  /**
   * Overridden from DeepLinkingMixin.
   */
  override beforeDeepLinkAttempt(settingId: Setting): boolean {
    if (settingId !== Setting.kSavedPrinters) {
      // Continue with deep link attempt.
      return true;
    }

    afterNextRender(this, () => {
      const savedPrinters = this.shadowRoot!.querySelector('#savedPrinters');
      const printerEntry = savedPrinters!.shadowRoot!.querySelector(
          'settings-cups-printers-entry');

      const deepLinkElement =
          printerEntry!.shadowRoot!.querySelector<CrIconButtonElement>(
              '#moreActions');

      if (!deepLinkElement || deepLinkElement.hidden) {
        console.warn(`Element with deep link id ${settingId} not focusable.`);
        return;
      }
      this.showDeepLinkElement(deepLinkElement);
    });
    // Stop deep link attempt since we completed it manually.
    return false;
  }

  override currentRouteChanged(route: Route): void {
    if (route !== routes.CUPS_PRINTERS) {
      if (this.onPrintersChangedListener_) {
        removeWebUiListener(this.onPrintersChangedListener_);
        this.onPrintersChangedListener_ = null;
      }
      this.entryManager_.removeWebUiListeners();
      return;
    }

    this.entryManager_.addWebUiListeners();
    this.onPrintersChangedListener_ = addWebUiListener(
        'on-saved-printers-changed', this.onSavedPrintersChanged_.bind(this));
    this.onEnterprisePrintersChangedListener_ = addWebUiListener(
        'on-enterprise-printers-changed',
        this.onEnterprisePrintersChanged_.bind(this));
    this.updateCupsPrintersList_();
    this.attemptDeepLink();
  }

  /**
   * CrosNetworkConfigObserver impl
   */
  override onActiveNetworksChanged(networks: NetworkStateProperties[]): void {
    this.hasActiveNetworkConnection = networks.some((network) => {
      // Note: Check for kOnline rather than using
      // OncMojo.connectionStateIsConnected() since the latter could return true
      // for networks without connectivity (e.g., captive portals).
      return network.connectionState === ConnectionStateType.kOnline;
    });
  }

  private openResultToast_(
      event:
          CustomEvent<{resultCode: PrinterSetupResult, printerName: string}>):
      void {
    const printerName = event.detail.printerName;
    switch (event.detail.resultCode) {
      case PrinterSetupResult.SUCCESS:
        this.addPrinterResultText_ = loadTimeData.getStringF(
            'printerAddedSuccessfulMessage', printerName);
        break;
      case PrinterSetupResult.EDIT_SUCCESS:
        this.addPrinterResultText_ = loadTimeData.getStringF(
            'printerEditedSuccessfulMessage', printerName);
        break;
      case PrinterSetupResult.PRINTER_UNREACHABLE:
        this.addPrinterResultText_ =
            loadTimeData.getStringF('printerUnavailableMessage', printerName);
        break;
      default:
        assertNotReached();
    }

    this.$.errorToast.show();
  }

  private addPrintServerAndShowResultToast_(
      event: CustomEvent<{printers: CupsPrintersList}>): void {
    this.entryManager_.addPrintServerPrinters(event.detail.printers);
    const length = event.detail.printers.printerList.length;
    if (length === 0) {
      this.addPrintServerResultText_ =
          loadTimeData.getString('printServerFoundZeroPrinters');
    } else if (length === 1) {
      this.addPrintServerResultText_ =
          loadTimeData.getString('printServerFoundOnePrinter');
    } else {
      this.addPrintServerResultText_ =
          loadTimeData.getStringF('printServerFoundManyPrinters', length);
    }
    this.$.printServerErrorToast.show();
  }

  private openManufacturerModelDialogForSpecifiedPrinter_(
      e: CustomEvent<{item: CupsPrinterInfo}>): void {
    const item = e.detail.item;
    this.$.addPrinterDialog.openManufacturerModelDialogForSpecifiedPrinter(
        item);
  }

  private updateCupsPrintersList_(): void {
    this.browserProxy_.getCupsSavedPrintersList().then(
        this.onSavedPrintersChanged_.bind(this));

    this.browserProxy_.getCupsEnterprisePrintersList().then(
        this.onEnterprisePrintersChanged_.bind(this));
  }

  private onSavedPrintersChanged_(cupsPrintersList: CupsPrintersList): void {
    this.savedPrinters_ = cupsPrintersList.printerList.map(
        printer => ({printerInfo: printer, printerType: PrinterType.SAVED}));
    this.entryManager_.setSavedPrintersList(this.savedPrinters_);
    // Used to delay rendering nearby and add printer sections to prevent
    // "Add Printer" flicker when clicking "Printers" in settings page.
    this.attemptedLoadingPrinters_ = true;
  }

  private onEnterprisePrintersChanged_(cupsPrintersList: CupsPrintersList):
      void {
    this.enterprisePrinters_ = cupsPrintersList.printerList.map(
        printer =>
            ({printerInfo: printer, printerType: PrinterType.ENTERPRISE}));
    this.entryManager_.setEnterprisePrintersList(this.enterprisePrinters_);
  }

  private onAddPrinterClick_(): void {
    this.$.addPrinterDialog.open();
    recordPrinterSettingsUserAction(
        PrinterSettingsUserAction.ADD_PRINTER_MANUALLY);
  }

  private onAddPrinterDialogClose_(): void {
    afterNextRender(this, () => {
      const icon = this.shadowRoot!.querySelector<CrIconButtonElement>(
          '#addManualPrinterButton');
      assert(icon);
      focusWithoutInk(icon);
    });
  }

  private onShowCupsEditPrinterDialog_(): void {
    this.showCupsEditPrinterDialog_ = true;
  }

  private onEditPrinterDialogClose_(): void {
    this.showCupsEditPrinterDialog_ = false;
  }

  /**
   * @return Returns if the 'no-search-results-found' string should be shown.
   */
  private showNoSearchResultsMessage_(searchTerm: string): boolean {
    if (!searchTerm || !this.printers.length) {
      return false;
    }
    searchTerm = searchTerm.toLowerCase();
    return !this.printers.some(printer => {
      return printer.printerName.toLowerCase().includes(searchTerm);
    });
  }


  private addPrinterButtonActive_(
      connectedToNetwork: boolean, userPrintersAllowed: boolean): boolean {
    return connectedToNetwork && userPrintersAllowed;
  }

  private doesAccountHaveSavedPrinters_(): boolean {
    return !!this.savedPrinters_.length;
  }

  private doesAccountHaveEnterprisePrinters_(): boolean {
    return !!this.enterprisePrinters_.length;
  }

  private getSavedPrintersAriaLabel_(): string {
    let printerLabel = '';
    if (this.savedPrinterCount_ === 0) {
      printerLabel = 'savedPrintersCountNone';
    } else if (this.savedPrinterCount_ === 1) {
      printerLabel = 'savedPrintersCountOne';
    } else {
      printerLabel = 'savedPrintersCountMany';
    }
    return loadTimeData.getStringF(printerLabel, this.savedPrinterCount_);
  }

  private getNearbyPrintersAriaLabel_(): string {
    let printerLabel = '';
    if (this.nearbyPrinterCount_ === 0) {
      printerLabel = 'nearbyPrintersCountNone';
    } else if (this.nearbyPrinterCount_ === 1) {
      printerLabel = 'nearbyPrintersCountOne';
    } else {
      printerLabel = 'nearbyPrintersCountMany';
    }
    return loadTimeData.getStringF(printerLabel, this.nearbyPrinterCount_);
  }

  private getEnterprisePrintersAriaLabel_(): string {
    let printerLabel = '';
    if (this.enterprisePrinterCount_ === 0) {
      printerLabel = 'enterprisePrintersCountNone';
    } else if (this.enterprisePrinterCount_ === 1) {
      printerLabel = 'enterprisePrintersCountOne';
    } else {
      printerLabel = 'enterprisePrintersCountMany';
    }
    return loadTimeData.getStringF(printerLabel, this.enterprisePrinterCount_);
  }

  private toggleClicked_(): void {
    this.nearbyPrintersExpanded_ = !this.nearbyPrintersExpanded_;

    // The iron list containing nearby printers does not get rendered while
    // hidden so the list needs to be refreshed when the Nearby printer
    // section is expanded.
    if (this.nearbyPrintersExpanded_) {
      this.shadowRoot!.querySelector('settings-cups-nearby-printers')!
          .resizePrintersList();
    }
  }

  private getIconDirection_(): string {
    return this.nearbyPrintersExpanded_ ? 'cr:expand-less' : 'cr:expand-more';
  }

  private onHelpLinkClicked_(): void {
    recordPrinterSettingsUserAction(PrinterSettingsUserAction.CLICK_HELP_LINK);
  }

  private computeNearbyPrintersEmpty_(): boolean {
    return this.nearbyPrinterCount_ === 0;
  }

  private onClickPrintManagement_(): void {
    this.browserProxy_.openPrintManagementApp();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'settings-cups-printers': SettingsCupsPrintersElement;
  }
  interface HTMLElementEventMap {
    'edit-cups-printer-details': CustomEvent;
    'show-cups-printer-toast':
        CustomEvent<{resultCode: PrinterSetupResult, printerName: string}>;
    'add-print-server-and-show-toast':
        CustomEvent<{printers: CupsPrintersList}>;
    'open-manufacturer-model-dialog-for-specified-printer':
        CustomEvent<{item: CupsPrinterInfo}>;
  }
}

customElements.define(
    SettingsCupsPrintersElement.is, SettingsCupsPrintersElement);