chromium/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_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-camera-subpage' contains a detailed overview about the
 * state of the system camera access.
 */

import './privacy_hub_app_permission_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 {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 {getTemplate} from './privacy_hub_camera_subpage.html.js';
import {CAMERA_SUBPAGE_USER_ACTION_HISTOGRAM_NAME, NUMBER_OF_POSSIBLE_USER_ACTIONS, PrivacyHubSensorSubpageUserAction} from './privacy_hub_metrics_util.js';

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

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

export class SettingsPrivacyHubCameraSubpage extends
    SettingsPrivacyHubCameraSubpageBase {
  static get is() {
    return 'settings-privacy-hub-camera-subpage' as const;
  }

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

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

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

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

      isCameraListEmpty_: {
        type: Boolean,
        computed: 'computeIsCameraListEmpty_(connectedCameraNames_)',
      },

      /**
       * Tracks if the Chrome code wants the camera switch to be disabled.
       */
      cameraSwitchForceDisabled_: {
        type: Boolean,
        value: false,
      },

      shouldDisableCameraToggle_: {
        type: Boolean,
        computed: 'computeShouldDisableCameraToggle_(isCameraListEmpty_, ' +
            'cameraSwitchForceDisabled_)',
      },

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

      cameraAccessStateText_: {
        type: String,
        computed: 'computeCameraAccessStateText_(' +
            'cameraFallbackMechanismEnabled_, prefs.ash.user.camera_allowed.*)',
      },
    };
  }

  private appList_: App[];
  private appPermissionsObserverReceiver_: AppPermissionsObserverReceiver|null;
  private browserProxy_: PrivacyHubBrowserProxy;
  private cameraAccessStateText_: string;
  private cameraFallbackMechanismEnabled_: boolean;
  private cameraSwitchForceDisabled_: boolean;
  private connectedCameraNames_: string[];
  private isCameraListEmpty_: boolean;
  private mojoInterfaceProvider_: AppPermissionsHandlerInterface;
  private shouldDisableCameraToggle_: boolean;
  private systemApps_: App[];

  constructor() {
    super();

    this.browserProxy_ = PrivacyHubBrowserProxyImpl.getInstance();

    this.mojoInterfaceProvider_ = getAppPermissionProvider();

    this.appPermissionsObserverReceiver_ = null;
  }

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

    this.addWebUiListener(
        'force-disable-camera-switch', (disabled: boolean) => {
          this.cameraSwitchForceDisabled_ = disabled;
        });
    this.browserProxy_.getInitialCameraSwitchForceDisabledState().then(
        (disabled) => {
          this.cameraSwitchForceDisabled_ = disabled;
        });
    this.browserProxy_.getCameraLedFallbackState().then((enabled) => {
      this.cameraFallbackMechanismEnabled_ = enabled;
    });

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

  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 async updateAppLists_(): Promise<void> {
    const apps = (await this.mojoInterfaceProvider_.getApps()).apps;
    this.appList_ = apps.filter(hasCameraPermission);

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

  private isCameraAllowed_(): boolean {
    return this.getPref('ash.user.camera_allowed').value;
  }

  private getSystemServicesPermissionText_(): string {
    return this.isCameraAllowed_() ?
        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 isCameraPermissionEnabled_(app: App): boolean {
    const permission = castExists(app.permissions[PermissionType.kCamera]);
    return isPermissionEnabled(permission.value);
  }

  /** Implements AppPermissionsObserver.OnAppUpdated */
  onAppUpdated(updatedApp: App): void {
    if (!hasCameraPermission(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 async updateCameraList_(): Promise<void> {
    const connectedCameraNames: string[] = [];
    const devices: MediaDeviceInfo[] =
        await MediaDevicesProxy.getMediaDevices().enumerateDevices();

    devices.forEach((device) => {
      if (device.kind === 'videoinput') {
        connectedCameraNames.push(device.label);
      }
    });

    this.connectedCameraNames_ = connectedCameraNames;
  }

  private computeIsCameraListEmpty_(): boolean {
    return this.connectedCameraNames_.length === 0;
  }

  private computeOnOffText_(): string {
    return this.isCameraAllowed_() ? this.i18n('deviceOn') :
                                     this.i18n('deviceOff');
  }

  private computeCameraAccessStateText_(): string {
    if (this.isCameraAllowed_()) {
      return this.cameraFallbackMechanismEnabled_ ?
          this.i18n('privacyHubCameraSubpageCameraToggleFallbackSubtext') :
          this.i18n('privacyHubCameraSubpageCameraToggleSubtext');
    } else {
      return this.i18n('privacyHubCameraAccessBlockedText');
    }
  }

  private computeShouldDisableCameraToggle_(): boolean {
    return this.cameraSwitchForceDisabled_ || this.isCameraListEmpty_;
  }

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

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

    this.getCameraToggle_().click();
  }

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

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

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

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

customElements.define(
    SettingsPrivacyHubCameraSubpage.is, SettingsPrivacyHubCameraSubpage);