chromium/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_microphone_subpage.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.

/**
 * @fileoverview
 * 'settings-privacy-hub-microphone-subpage' contains a detailed overview about
 * the state of the system microphone access.
 */

import './privacy_hub_app_permission_row.js';
import './privacy_hub_system_service_row.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrToggleElement} from 'chrome://resources/ash/common/cr_elements/cr_toggle/cr_toggle.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 {PermissionType} from 'chrome://resources/cr_components/app_management/app_management.mojom-webui.js';
import {isPermissionEnabled} from 'chrome://resources/cr_components/app_management/permission_util.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {castExists} from '../assert_extras.js';
import {MediaDevicesProxy} from '../common/media_devices_proxy.js';
import {App, AppPermissionsHandlerInterface, AppPermissionsObserverReceiver} from '../mojom-webui/app_permission_handler.mojom-webui.js';

import {getAppPermissionProvider} from './mojo_interface_provider.js';
import {PrivacyHubBrowserProxy, PrivacyHubBrowserProxyImpl} from './privacy_hub_browser_proxy.js';
import {MICROPHONE_SUBPAGE_USER_ACTION_HISTOGRAM_NAME, NUMBER_OF_POSSIBLE_USER_ACTIONS, PrivacyHubSensorSubpageUserAction} from './privacy_hub_metrics_util.js';
import {getTemplate} from './privacy_hub_microphone_subpage.html.js';

/**
 * Whether the app has microphone permission defined.
 * */
function hasMicrophonePermission(app: App): boolean {
  return app.permissions[PermissionType.kMicrophone] !== undefined;
}

const SettingsPrivacyHubMicrophoneSubpageBase =
    WebUiListenerMixin(I18nMixin(PrefsMixin(PolymerElement)));

export class SettingsPrivacyHubMicrophoneSubpage extends
    SettingsPrivacyHubMicrophoneSubpageBase {
  static get is() {
    return 'settings-privacy-hub-microphone-subpage' as const;
  }

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

  static get properties() {
    return {
      /**
       * Apps with microphone permission.
       * Only contains apps that are displayed in the App Management page.
       * Does not contain system apps.
       */
      appList_: {
        type: Array,
        value: [],
      },

      systemApps_: {
        type: Array,
        value: [],
      },

      connectedMicrophoneNames_: {
        type: Array,
        value: [],
      },

      /**
       * Indicates whether `connectedMicrophoneNames_` is empty.
       */
      isMicListEmpty_: {
        type: Boolean,
        computed: 'computeIsMicListEmpty_(connectedMicrophoneNames_)',
      },

      /**
       * Indicates whether the microphone hardware toggle is active.
       */
      microphoneHardwareToggleActive_: {
        type: Boolean,
        value: false,
      },

      /**
       * Indicates whether the `cr-toggle` for microphone should be disabled.
       */
      shouldDisableMicrophoneToggle_: {
        type: Boolean,
        computed: 'computeShouldDisableMicrophoneToggle_(isMicListEmpty_, ' +
            'microphoneHardwareToggleActive_)',
      },
    };
  }

  private appList_: App[];
  private appPermissionsObserverReceiver_: AppPermissionsObserverReceiver|null;
  private browserProxy_: PrivacyHubBrowserProxy;
  private connectedMicrophoneNames_: string[];
  private isMicListEmpty_: boolean;
  private microphoneHardwareToggleActive_: boolean;
  private mojoInterfaceProvider_: AppPermissionsHandlerInterface;
  private shouldDisableMicrophoneToggle_: boolean;
  private systemApps_: App[];

  constructor() {
    super();

    this.browserProxy_ = PrivacyHubBrowserProxyImpl.getInstance();

    this.mojoInterfaceProvider_ = getAppPermissionProvider();

    this.appPermissionsObserverReceiver_ = null;
  }

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

    this.addWebUiListener(
        'microphone-hardware-toggle-changed', (enabled: boolean) => {
          this.setMicrophoneHardwareToggleState_(enabled);
        });
    this.browserProxy_.getInitialMicrophoneHardwareToggleState().then(
        (enabled) => {
          this.setMicrophoneHardwareToggleState_(enabled);
        });

    this.updateMicrophoneList_();
    MediaDevicesProxy.getMediaDevices().addEventListener(
        'devicechange', () => this.updateMicrophoneList_());
  }

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

    this.appPermissionsObserverReceiver_ =
        new AppPermissionsObserverReceiver(this);
    this.mojoInterfaceProvider_.addObserver(
        this.appPermissionsObserverReceiver_.$.bindNewPipeAndPassRemote());

    this.updateAppLists_();
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();
    this.appPermissionsObserverReceiver_!.$.close();
  }

  private setMicrophoneHardwareToggleState_(enabled: boolean): void {
    this.microphoneHardwareToggleActive_ = enabled;
  }

  private async updateAppLists_(): Promise<void> {
    const apps = (await this.mojoInterfaceProvider_.getApps()).apps;
    this.appList_ = apps.filter(hasMicrophonePermission);

    this.systemApps_ =
        (await this.mojoInterfaceProvider_.getSystemAppsThatUseMicrophone())
            .apps;
  }

  private getSystemServicesPermissionText_(): string {
    const microphoneAllowed =
        this.getPref<string>('ash.user.microphone_allowed').value;
    return microphoneAllowed ?
        this.i18n('privacyHubSystemServicesAllowedText') :
        this.i18n('privacyHubSystemServicesBlockedText');
  }

  /**
   * The function is used for sorting app names alphabetically.
   */
  private alphabeticalSort_(first: App, second: App): number {
    return first.name!.localeCompare(second.name!);
  }

  private async updateMicrophoneList_(): Promise<void> {
    const connectedMicrophoneNames: string[] = [];
    const devices: MediaDeviceInfo[] =
        await MediaDevicesProxy.getMediaDevices().enumerateDevices();

    devices.forEach((device) => {
      if (device.kind === 'audioinput' && device.deviceId !== 'default') {
        connectedMicrophoneNames.push(device.label);
      }
    });

    this.connectedMicrophoneNames_ = connectedMicrophoneNames;
  }

  private computeIsMicListEmpty_(): boolean {
    return this.connectedMicrophoneNames_.length === 0;
  }

  private computeOnOffText_(): string {
    const microphoneAllowed =
        this.getPref<string>('ash.user.microphone_allowed').value;
    return microphoneAllowed ? this.i18n('deviceOn') : this.i18n('deviceOff');
  }

  private computeOnOffSubtext_(): string {
    const microphoneAllowed =
        this.getPref<string>('ash.user.microphone_allowed').value;
    return microphoneAllowed ?
        this.i18n('privacyHubMicrophoneSubpageMicrophoneToggleSubtext') :
        this.i18n('privacyHubMicrophoneAccessBlockedText');
  }

  private computeShouldDisableMicrophoneToggle_(): boolean {
    return this.microphoneHardwareToggleActive_ || this.isMicListEmpty_;
  }

  private onManagePermissionsInChromeRowClick_(): void {
    chrome.metricsPrivate.recordEnumerationValue(
        MICROPHONE_SUBPAGE_USER_ACTION_HISTOGRAM_NAME,
        PrivacyHubSensorSubpageUserAction.WEBSITE_PERMISSION_LINK_CLICKED,
        NUMBER_OF_POSSIBLE_USER_ACTIONS);

    this.mojoInterfaceProvider_.openBrowserPermissionSettings(
        PermissionType.kMicrophone);
  }

  /**
   * Returns true if the microphone permission of the app is in Allowed or
   * equivalent state.
   */
  private isMicrophonePermissionEnabled_(app: App): boolean {
    assert(hasMicrophonePermission(app));
    return isPermissionEnabled(
        app.permissions[PermissionType.kMicrophone]!.value);
  }

  /** Implements AppPermissionsObserver.OnAppUpdated */
  onAppUpdated(updatedApp: App): void {
    if (!hasMicrophonePermission(updatedApp)) {
      return;
    }
    const idx = this.appList_.findIndex(app => app.id === updatedApp.id);
    if (idx === -1) {
      // New app installed.
      this.push('appList_', updatedApp);
    } else {
      // An already installed app is updated.
      this.splice('appList_', idx, 1, updatedApp);
    }
  }

  /** Implements AppPermissionsObserver.OnAppRemoved */
  onAppRemoved(appId: string): void {
    const idx = this.appList_.findIndex(app => app.id === appId);
    if (idx !== -1) {
      this.splice('appList_', idx, 1);
    }
  }

  private getMicrophoneToggle_(): CrToggleElement {
    return castExists(
        this.shadowRoot!.querySelector<CrToggleElement>('#microphoneToggle'));
  }

  private onAccessStatusRowClick_(): void {
    if (this.shouldDisableMicrophoneToggle_) {
      return;
    }

    this.getMicrophoneToggle_().click();
  }

  private onMicrophoneToggleClick_(): void {
    chrome.metricsPrivate.recordEnumerationValue(
        MICROPHONE_SUBPAGE_USER_ACTION_HISTOGRAM_NAME,
        PrivacyHubSensorSubpageUserAction.SYSTEM_ACCESS_CHANGED,
        NUMBER_OF_POSSIBLE_USER_ACTIONS);
  }
}

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

customElements.define(
    SettingsPrivacyHubMicrophoneSubpage.is,
    SettingsPrivacyHubMicrophoneSubpage);