chromium/chrome/browser/resources/nearby_internals/cross_device_internals.ts

// Copyright 2023 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_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import './shared_style.css.js';
import './np_list_object.js';
import './logging_tab.js';
import './log_object.js';
import './log_types.js';
import '//resources/ash/common/cr_elements/md_select.css.js';
import '//resources/ash/common/cr_elements/cros_color_overrides.css.js';
import 'chrome://resources/polymer/v3_0/iron-location/iron-location.js';
import 'chrome://resources/polymer/v3_0/iron-pages/iron-pages.js';

import {WebUiListenerMixin} from 'chrome://resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './cross_device_internals.html.js';
import {NearbyLogsBrowserProxy} from './cross_device_logs_browser_proxy.js';
import type {LogTypesElement} from './log_types.js';
import {NearbyPrefsBrowserProxy} from './nearby_prefs_browser_proxy.js';
import {NearbyPresenceBrowserProxy} from './nearby_presence_browser_proxy.js';
import {NearbyUiTriggerBrowserProxy} from './nearby_ui_trigger_browser_proxy.js';
import type {LogMessage, LogProvider, PresenceDevice, SelectOption} from './types.js';
import {ActionValues, FeatureValues, Severity} from './types.js';

/**
 * Converts log message to string format for saved download file.
 */
function logToSavedString(log: LogMessage): string {
  // Convert to string value for |line.severity|.
  let severity;
  switch (log.severity) {
    case Severity.INFO:
      severity = 'INFO';
      break;
    case Severity.WARNING:
      severity = 'WARNING';
      break;
    case Severity.ERROR:
      severity = 'ERROR';
      break;
    case Severity.VERBOSE:
      severity = 'VERBOSE';
      break;
  }

  // Reduce the file path to just the file name for logging simplification.
  const file = log.file.substring(log.file.lastIndexOf('/') + 1);

  return `[${log.time} ${severity} ${file} (${log.line})] ${log.text}\n`;
}

const CrossDeviceInternalsElementBase = WebUiListenerMixin(PolymerElement);

class CrossDeviceInternalsElement extends CrossDeviceInternalsElementBase {
  static get is() {
    return 'cross-device-internals';
  }

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

  static get properties() {
    return {

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

      featuresList_: {
        type: Array,
        value: [
          {name: 'Nearby Infra', value: FeatureValues.NEARBY_INFRA},
          {name: 'Nearby Share', value: FeatureValues.NEARBY_SHARE},
          {name: 'Fast Pair', value: FeatureValues.FAST_PAIR},
        ],
      },

      nearbyInfraActionList_: {
        type: Array,
        value: [
          {name: 'NP: Start Scan', value: ActionValues.START_SCAN},
          {name: 'NP: Stop Scan', value: ActionValues.STOP_SCAN},
          {name: 'NP: Sync Credentials', value: ActionValues.SYNC_CREDENTIALS},
          {name: 'NP: First time flow', value: ActionValues.FIRST_TIME_FLOW},
          {
            name: 'NP: Send Update Credentials Message',
            value: ActionValues.SEND_UPDATE_CREDENTIALS_MESSAGE,
          },
        ],
      },

      logLevelList_: {
        type: Array,
        value: [
          {name: 'VERBOSE', value: Severity.VERBOSE},
          {name: 'INFO', value: Severity.INFO},
          {name: 'WARNING', value: Severity.WARNING},
          {name: 'ERROR', value: Severity.ERROR},
        ],
      },

      nearbyShareActionList_: {
        type: Array,
        value: [
          {name: 'Reset Nearby Share', value: ActionValues.RESET_NEARBY_SHARE},
        ],
      },

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

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

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

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

      currentSeverity: {
        type: Severity,
        value: Severity.VERBOSE,
      },

      currentLogTypes: {
        type: FeatureValues,
        value: [
          FeatureValues.NEARBY_SHARE,
          FeatureValues.NEARBY_INFRA,
          FeatureValues.FAST_PAIR,
        ],
      },
    };
  }

  private npDiscoveredDevicesList_: PresenceDevice[];
  private featuresList_: SelectOption[];
  private nearbyInfraActionList_: SelectOption[];
  private nearbyShareActionList_: SelectOption[];
  private fastPairActionList_: SelectOption[];
  private actionsSelectList_: SelectOption[];
  private logList_: LogMessage[];
  private filteredLogList_: LogMessage[];
  private currentFilter_: string;
  private currentSeverity: Severity;
  private logLevelList_: SelectOption[];
  private logProvider_: LogProvider;
  private currentLogTypes: FeatureValues[];

  private nearbyPresenceBrowserProxy_: NearbyPresenceBrowserProxy =
      NearbyPresenceBrowserProxy.getInstance();
  private prefsBrowserProxy_: NearbyPrefsBrowserProxy =
      NearbyPrefsBrowserProxy.getInstance();
  private nearbyUITriggerBrowserProxy_: NearbyUiTriggerBrowserProxy =
      NearbyUiTriggerBrowserProxy.getInstance();

  /**
   * When the page is initialized, notify the C++ layer and load in the
   * contents of its log buffer. Initialize WebUI Listeners.
   */
  override connectedCallback() {
    super.connectedCallback();

    this.nearbyPresenceBrowserProxy_.initialize();
    this.nearbyUITriggerBrowserProxy_.initialize();
    this.addWebUiListener(
        'presence-device-found',
        (device: PresenceDevice) => this.onPresenceDeviceFound_(device));
    this.addWebUiListener(
        'presence-device-changed',
        (device: PresenceDevice) => this.onPresenceDeviceChanged_(device));
    this.addWebUiListener(
        'presence-device-lost',
        (device: PresenceDevice) => this.onPresenceDeviceLost_(device));
    this.set('actionsSelectList_', this.nearbyInfraActionList_);

    this.logProvider_ = {
      messageAddedEventName: 'log-message-added',
      bufferClearedEventName: 'log-buffer-cleared',
      logFilePrefix: 'cross_device_logs_',
      getLogMessages: () =>
          NearbyLogsBrowserProxy.getInstance().getLogMessages(),
    };
    this.addWebUiListener(
        this.logProvider_.messageAddedEventName,
        (log: LogMessage) => this.onLogMessageAdded_(log));
    this.addWebUiListener(
        this.logProvider_.bufferClearedEventName,
        () => this.onWebUiLogBufferCleared_());
    this.logProvider_.getLogMessages().then(
        (logs: LogMessage[]) => this.onGetLogMessages_(logs));
  }

  private updateActionsSelect_() {
    const actionGroup: HTMLSelectElement|null =
        this.shadowRoot!.querySelector('#actionGroup');

    if (actionGroup) {
      switch (Number(actionGroup.value)) {
        case FeatureValues.NEARBY_INFRA:
          this.set('actionsSelectList_', this.nearbyInfraActionList_);
          break;
        case FeatureValues.NEARBY_SHARE:
          this.set('actionsSelectList_', this.nearbyShareActionList_);
          break;
        case FeatureValues.FAST_PAIR:
          this.set('actionsSelectList_', this.fastPairActionList_);
          break;
      }
    }
  }

  private performAction_() {
    const actionSelect: HTMLSelectElement|null =
        this.shadowRoot!.querySelector('#actionSelect');
    if (actionSelect) {
      switch (Number(actionSelect.value)) {
        case ActionValues.START_SCAN:
          this.nearbyPresenceBrowserProxy_.sendStartScan();
          break;
        case ActionValues.STOP_SCAN:
          this.nearbyPresenceBrowserProxy_.sendStopScan();
          break;
        case ActionValues.SYNC_CREDENTIALS:
          this.nearbyPresenceBrowserProxy_.sendSyncCredentials();
          break;
        case ActionValues.FIRST_TIME_FLOW:
          this.nearbyPresenceBrowserProxy_.sendFirstTimeFlow();
          break;
        case ActionValues.RESET_NEARBY_SHARE:
          this.prefsBrowserProxy_.clearNearbyPrefs();
          break;
        case ActionValues.SEND_UPDATE_CREDENTIALS_MESSAGE:
          this.nearbyPresenceBrowserProxy_
              .sendUpdateCredentialsPushNotificationMessage();
          break;
        case ActionValues.SHOW_RECEIVED_NOTIFICATION:
          this.nearbyUITriggerBrowserProxy_
              .showNearbyShareReceivedNotification();
          break;
        default:
          break;
      }
    }
  }

  private onPresenceDeviceFound_(device: PresenceDevice): void {
    const type = device['type'];
    const endpointId = device['endpoint_id'];
    const actions = device['actions'];

    // If there is not a device with this endpoint_id currently in the devices
    // list, add it.
    if (!this.npDiscoveredDevicesList_.find(
            listDevice => listDevice.endpoint_id === endpointId)) {
      this.unshift('npDiscoveredDevicesList_', {
        'connectable': true,
        'type': type,
        'endpoint_id': endpointId,
        'actions': actions,
      });
    }
  }

  // TODO(b/277820435): Add and update device name for devices that have names
  // included.
  private onPresenceDeviceChanged_(device: PresenceDevice): void {
    const type = device['type'];
    const endpointId = device['endpoint_id'];
    const actions = device['actions'];

    const index = this.npDiscoveredDevicesList_.findIndex(
        listDevice => listDevice.endpoint_id === endpointId);

    // If a device was changed but we don't have a record of it being found,
    // add it to the array like performActiononPresenceDeviceFound__().
    if (index === -1) {
      this.unshift('npDiscoveredDevicesList_', {
        'connectable': true,
        'type': type,
        'endpoint_id': endpointId,
        'actions': actions,
      });
      return;
    }

    this.npDiscoveredDevicesList_[index] = {
      'connectable': true,
      'type': type,
      'endpoint_id': endpointId,
      'actions': actions,
    };
  }

  private onPresenceDeviceLost_(device: PresenceDevice): void {
    const type = device['type'];
    const endpointId = device['endpoint_id'];
    const actions = device['actions'];

    const index = this.npDiscoveredDevicesList_.findIndex(
        listDevice => listDevice.endpoint_id === endpointId);

    // The device was not found in the list.
    if (index === -1) {
      return;
    }

    this.npDiscoveredDevicesList_[index] = {
      'connectable': false,
      'type': type,
      'endpoint_id': endpointId,
      'actions': actions,
    };
  }


  /**
   * Clears javascript logs displayed, but c++ log buffer remains.
   */
  private onClearLogsButtonClicked_(): void {
    this.clearLogBuffer_();
  }

  /**
   * Saves and downloads all javascript logs.
   */
  private onSaveUnfilteredLogsButtonClicked_(): void {
    this.onSaveLogsButtonClicked_(false);
  }

  /**
   * Saves and downloads javascript logs that currently appear on the page.
   */
  private onSaveFilteredLogsButtonClicked_(): void {
    this.onSaveLogsButtonClicked_(true);
  }

  /**
   * Saves and downloads javascript logs.
   */
  private onSaveLogsButtonClicked_(filtered: boolean): void {
    let blob;
    if (filtered) {
      blob = new Blob(
          this.filteredLogList_.map(logToSavedString),
          {type: 'text/plain;charset=utf-8'});
    } else {
      blob = new Blob(
          this.logList_.map(logToSavedString),
          {type: 'text/plain;charset=utf-8'});
    }
    const url = URL.createObjectURL(blob);

    const anchorElement = document.createElement('a');
    anchorElement.href = url;
    anchorElement.download =
        this.logProvider_.logFilePrefix + new Date().toJSON() + '.txt';
    document.body.appendChild(anchorElement);
    anchorElement.click();

    window.setTimeout(function() {
      document.body.removeChild(anchorElement);
      window.URL.revokeObjectURL(url);
    }, 0);
  }

  /**
   * Adds a log message to the javascript log list displayed. Called from the
   * C++ WebUI handler when a log message is added to the log buffer.
   */
  private onLogMessageAdded_(log: LogMessage): void {
    this.push('logList_', log);
    if ((log.text.match(this.currentFilter_) ||
         log.file.match(this.currentFilter_)) &&
        log.severity >= this.currentSeverity &&
        this.currentLogTypes.includes(log.feature)) {
      this.push('filteredLogList_', log);
    }
  }

  private addLogFilter_(): void {
    const logLevelSelector: HTMLSelectElement|null =
        this.shadowRoot!.querySelector('#logLevelSelector');
    if (logLevelSelector) {
      switch (Number(logLevelSelector.value)) {
        case Severity.VERBOSE:
          this.set(
              'filteredLogList_',
              this.logList_.filter((log) => log.severity >= Severity.VERBOSE));
          this.currentSeverity = Severity.VERBOSE;
          break;
        case Severity.INFO:
          this.set(
              'filteredLogList_',
              this.logList_.filter((log) => log.severity >= Severity.INFO));
          this.currentSeverity = Severity.INFO;
          break;
        case Severity.WARNING:
          this.set(
              'filteredLogList_',
              this.logList_.filter((log) => log.severity >= Severity.WARNING));
          this.currentSeverity = Severity.WARNING;
          break;
        case Severity.ERROR:
          this.set(
              'filteredLogList_',
              this.logList_.filter((log) => log.severity >= Severity.ERROR));
          this.currentSeverity = Severity.ERROR;
          break;
      }
    }
    const logType: LogTypesElement|null =
        this.shadowRoot!.querySelector('#logType');
    if (logType) {
      this.set(
          'currentLogTypes',
          logType.currentLogTypes,
      );
    }

    this.set(
        'filteredLogList_',
        this.filteredLogList_.filter(
            (log: LogMessage) => this.currentLogTypes.includes(log.feature)));

    const logSearch: HTMLSelectElement|null =
        this.shadowRoot!.querySelector('#logSearch');
    if (logSearch) {
      this.currentFilter_ = logSearch.value;
      this.set(
          'filteredLogList_',
          this.filteredLogList_.filter(
              (log: LogMessage) =>
                  (log.text.match(this.currentFilter_) ||
                   log.file.match(this.currentFilter_))));
    }
  }

  /**
   * Called in response to WebUI handler clearing log buffer.
   */
  private onWebUiLogBufferCleared_(): void {
    this.clearLogBuffer_();
  }

  /**
   * Parses an array of log messages and adds to the javascript list sent in
   * from the initial page load.
   */
  private onGetLogMessages_(logs: LogMessage[]): void {
    this.logList_ = logs.concat(this.logList_);
    this.filteredLogList_ = logs.slice();
  }

  /**
   * Clears the javascript log buffer.
   */
  private clearLogBuffer_(): void {
    this.logList_ = [];
    this.filteredLogList_ = [];
  }
}

customElements.define(
    CrossDeviceInternalsElement.is, CrossDeviceInternalsElement);