privacy-hub: Implement the first section of camera subpage
This CL implements the first section of the camera subpage. This section
informs the user about the current state of the camera system access.
The section also lists the cameras connected to the device when camera
access is in allowed state.
UI:
https://screenshot.googleplex.com/3yFPzGKxuBoRrhE
https://screenshot.googleplex.com/699NnHiVf9S6qUo
https://screenshot.googleplex.com/9XZ3CaXuPiFacvg
Bug: b:310170975
Change-Id: I6a6f95a8aa8ac2d45db6f582bff7207b50ff1533
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5019401
Reviewed-by: Wes Okuhara <wesokuhara@google.com>
Commit-Queue: Md Shahadat Hossain Shahin <shahinmd@google.com>
Cr-Commit-Position: refs/heads/main@{#1224467}
diff --git a/chrome/app/os_settings_strings.grdp b/chrome/app/os_settings_strings.grdp
index 480c6ec..37c654c 100644
--- a/chrome/app/os_settings_strings.grdp
+++ b/chrome/app/os_settings_strings.grdp
@@ -6289,6 +6289,9 @@
<message name="IDS_OS_SETTINGS_PRIVACY_HUB_NO_APP_CAN_USE_MIC_TEXT" translateable="false" desc="The text displayed in the Apps section of the microphone subpage when microphone is not allowed.">
No app is allowed to use your microphone
</message>
+ <message name="IDS_OS_SETTINGS_PRIVACY_HUB_BLOCKED_FOR_ALL_TEXT" translateable="false" desc="The text displayed as a sublabel in privacy hub page and in sensor subpages when sensor access is disabled.">
+ Blocked for all
+ </message>
<message name="IDS_OS_SETTINGS_REVAMP_SECURE_DNS" desc="Text for secure DNS toggle in Privacy options for ChromeOS">
Encrypt URLs entered into the browser
</message>
diff --git a/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.html b/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.html
index 0108668..0643801 100644
--- a/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.html
+++ b/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.html
@@ -1 +1,52 @@
-<div>Camera subpage</div>
+<style include="settings-shared">
+ .list-item:not(:last-of-type) {
+ border-bottom: var(--cr-separator-line);
+ }
+
+ #onOffText[on] {
+ color: var(--cros-sys-primary);
+ }
+
+ #accessStatusRow:hover[actionable] {
+ background-color: var(--cr-hover-background-color);
+ }
+</style>
+<div
+ id="accessStatusRow"
+ class="settings-box first"
+ actionable$="[[!shouldDisableCameraToggle_]]"
+ on-click="onAccessStatusRowClick_">
+ <div class="start settings-box-text" aria-hidden="true">
+ <div id="onOffText" on$="[[prefs.ash.user.camera_allowed.value]]">
+ [[computeOnOffText_(prefs.ash.user.camera_allowed.value)]]
+ </div>
+ <div id="onOffSubtext" class="secondary">
+ [[computeOnOffSubtext_(prefs.ash.user.camera_allowed.value)]]
+ </div>
+ </div>
+ <div id="cameraToggleWrapper">
+ <cr-toggle
+ id="cameraToggle"
+ checked="{{prefs.ash.user.camera_allowed.value}}"
+ disabled="[[shouldDisableCameraToggle_]]">
+ </cr-toggle>
+ </div>
+</div>
+<template is="dom-if" if="[[prefs.ash.user.camera_allowed.value]]" restamp>
+ <div id="cameraListSection" class="list-frame">
+ <template is="dom-if" if="[[isCameraListEmpty_]]" restamp>
+ <div id="noCameraText" class="list-item">
+ $i18n{noCameraConnectedText}
+ </div>
+ </template>
+ <template is="dom-if" if="[[!isCameraListEmpty_]]" restamp>
+ <template id="cameraList" is="dom-repeat"
+ items="[[connectedCameras_]]">
+ <div class="list-item">
+ [[item]]
+ </div>
+ </template>
+ </template>
+ </div>
+</template>
+<div class="hr"></div>
\ No newline at end of file
diff --git a/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.ts b/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.ts
index 06e5d08..5221bc5 100644
--- a/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.ts
+++ b/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_camera_subpage.ts
@@ -8,11 +8,23 @@
* state of the system camera access.
*/
+import {PrefsMixin} from 'chrome://resources/cr_components/settings_prefs/prefs_mixin.js';
+import {CrToggleElement} from 'chrome://resources/cr_elements/cr_toggle/cr_toggle.js';
+import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
+import {WebUiListenerMixin} from 'chrome://resources/cr_elements/web_ui_listener_mixin.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
+import {castExists} from '../assert_extras.js';
+
+import {MediaDevicesProxy} from './media_devices_proxy.js';
+import {PrivacyHubBrowserProxy, PrivacyHubBrowserProxyImpl} from './privacy_hub_browser_proxy.js';
import {getTemplate} from './privacy_hub_camera_subpage.html.js';
-export class SettingsPrivacyHubCameraSubpage extends PolymerElement {
+const SettingsPrivacyHubCameraSubpageBase =
+ WebUiListenerMixin(I18nMixin(PrefsMixin(PolymerElement)));
+
+export class SettingsPrivacyHubCameraSubpage extends
+ SettingsPrivacyHubCameraSubpageBase {
static get is() {
return 'settings-privacy-hub-camera-subpage' as const;
}
@@ -20,6 +32,106 @@
static get template() {
return getTemplate();
}
+
+ static get properties() {
+ return {
+ connectedCameras_: {
+ type: Array,
+ value: [],
+ },
+
+ isCameraListEmpty_: {
+ type: Boolean,
+ computed: 'computeIsCameraListEmpty_(connectedCameras_)',
+ },
+
+ /**
+ * Tracks if the Chrome code wants the camera switch to be disabled.
+ */
+ cameraSwitchForceDisabled_: {
+ type: Boolean,
+ value: false,
+ },
+
+ shouldDisableCameraToggle_: {
+ type: Boolean,
+ computed: 'computeShouldDisableCameraToggle_(isCameraListEmpty_, ' +
+ 'cameraSwitchForceDisabled_)',
+ },
+
+ };
+ }
+
+ private browserProxy_: PrivacyHubBrowserProxy;
+ private cameraSwitchForceDisabled_: boolean;
+ private connectedCameras_: string[];
+ private isCameraListEmpty_: boolean;
+ private shouldDisableCameraToggle_: boolean;
+
+ constructor() {
+ super();
+
+ this.browserProxy_ = PrivacyHubBrowserProxyImpl.getInstance();
+ }
+
+ 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.updateCameraList_();
+ MediaDevicesProxy.getMediaDevices().addEventListener(
+ 'devicechange', () => this.updateCameraList_());
+ }
+
+ private async updateCameraList_(): Promise<void> {
+ const connectedCameras: string[] = [];
+ const devices: MediaDeviceInfo[] =
+ await MediaDevicesProxy.getMediaDevices().enumerateDevices();
+
+ devices.forEach((device) => {
+ if (device.kind === 'videoinput') {
+ connectedCameras.push(device.label);
+ }
+ });
+
+ this.connectedCameras_ = connectedCameras;
+ }
+
+ private computeIsCameraListEmpty_(): boolean {
+ return this.connectedCameras_.length === 0;
+ }
+
+ private computeOnOffText_(): string {
+ const cameraAllowed = this.getPref<string>('ash.user.camera_allowed').value;
+ return cameraAllowed ? this.i18n('deviceOn') : this.i18n('deviceOff');
+ }
+
+ private computeOnOffSubtext_(): string {
+ const cameraAllowed = this.getPref<string>('ash.user.camera_allowed').value;
+ return cameraAllowed ? this.i18n('cameraToggleSubtext') :
+ this.i18n('blockedForAllText');
+ }
+
+ private computeShouldDisableCameraToggle_(): boolean {
+ return this.cameraSwitchForceDisabled_ || this.isCameraListEmpty_;
+ }
+
+ private getCameraToggle_(): CrToggleElement {
+ return castExists(
+ this.shadowRoot!.querySelector<CrToggleElement>('#cameraToggle'));
+ }
+
+ private onAccessStatusRowClick_(): void {
+ this.getCameraToggle_().click();
+ }
}
declare global {
diff --git a/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_microphone_subpage.ts b/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_microphone_subpage.ts
index 25e5c5f..de57b4dc 100644
--- a/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_microphone_subpage.ts
+++ b/chrome/browser/resources/ash/settings/os_privacy_page/privacy_hub_microphone_subpage.ts
@@ -179,7 +179,7 @@
const microphoneAllowed =
this.getPref<string>('ash.user.microphone_allowed').value;
return microphoneAllowed ? this.i18n('microphoneToggleSubtext') :
- 'Blocked for all';
+ this.i18n('blockedForAllText');
}
private computeShouldDisableMicrophoneToggle_(): boolean {
diff --git a/chrome/browser/ui/webui/ash/settings/pages/privacy/privacy_section.cc b/chrome/browser/ui/webui/ash/settings/pages/privacy/privacy_section.cc
index 4537bc0..733668d2 100644
--- a/chrome/browser/ui/webui/ash/settings/pages/privacy/privacy_section.cc
+++ b/chrome/browser/ui/webui/ash/settings/pages/privacy/privacy_section.cc
@@ -488,6 +488,7 @@
{"privacyHubPermissionDeniedText", IDS_APP_MANAGEMENT_PERMISSION_DENIED},
{"noAppCanUseMicText",
IDS_OS_SETTINGS_PRIVACY_HUB_NO_APP_CAN_USE_MIC_TEXT},
+ {"blockedForAllText", IDS_OS_SETTINGS_PRIVACY_HUB_BLOCKED_FOR_ALL_TEXT},
};
html_source->AddLocalizedStrings(kLocalizedStrings);
diff --git a/chrome/test/data/webui/settings/chromeos/BUILD.gn b/chrome/test/data/webui/settings/chromeos/BUILD.gn
index e1f39cf..d7cef64 100644
--- a/chrome/test/data/webui/settings/chromeos/BUILD.gn
+++ b/chrome/test/data/webui/settings/chromeos/BUILD.gn
@@ -273,6 +273,7 @@
"os_privacy_page/manage_users_subpage_test.ts",
"os_privacy_page/os_privacy_page_test.ts",
"os_privacy_page/privacy_hub_app_permission_row_test.ts",
+ "os_privacy_page/privacy_hub_camera_subpage_test.ts",
"os_privacy_page/privacy_hub_microphone_subpage_test.ts",
"os_privacy_page/privacy_hub_subpage_test.ts",
"os_privacy_page/smart_privacy_subpage_test.ts",
diff --git a/chrome/test/data/webui/settings/chromeos/os_privacy_page/privacy_hub_camera_subpage_test.ts b/chrome/test/data/webui/settings/chromeos/os_privacy_page/privacy_hub_camera_subpage_test.ts
new file mode 100644
index 0000000..23daede
--- /dev/null
+++ b/chrome/test/data/webui/settings/chromeos/os_privacy_page/privacy_hub_camera_subpage_test.ts
@@ -0,0 +1,239 @@
+// 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://os-settings/lazy_load.js';
+
+import {MediaDevicesProxy, PrivacyHubBrowserProxyImpl, SettingsPrivacyHubCameraSubpage} from 'chrome://os-settings/lazy_load.js';
+import {CrToggleElement, Router} from 'chrome://os-settings/os_settings.js';
+import {webUIListenerCallback} from 'chrome://resources/js/cr.js';
+import {DomRepeat, flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
+import {assertEquals, assertFalse, assertNull, assertTrue} from 'chrome://webui-test/chai_assert.js';
+import {flushTasks} from 'chrome://webui-test/polymer_test_util.js';
+import {isVisible} from 'chrome://webui-test/test_util.js';
+
+import {FakeMediaDevices} from '../fake_media_devices.js';
+
+import {TestPrivacyHubBrowserProxy} from './test_privacy_hub_browser_proxy.js';
+
+suite('<settings-privacy-hub-camera-subpage>', () => {
+ let privacyHubCameraSubpage: SettingsPrivacyHubCameraSubpage;
+ let privacyHubBrowserProxy: TestPrivacyHubBrowserProxy;
+ let mediaDevices: FakeMediaDevices;
+
+ setup(() => {
+ privacyHubBrowserProxy = new TestPrivacyHubBrowserProxy();
+ PrivacyHubBrowserProxyImpl.setInstanceForTesting(privacyHubBrowserProxy);
+
+ mediaDevices = new FakeMediaDevices();
+ MediaDevicesProxy.setMediaDevicesForTesting(mediaDevices);
+
+ privacyHubCameraSubpage =
+ document.createElement('settings-privacy-hub-camera-subpage');
+ const prefs = {
+ 'ash': {
+ 'user': {
+ 'camera_allowed': {
+ value: true,
+ },
+ },
+ },
+ };
+ privacyHubCameraSubpage.prefs = prefs;
+ document.body.appendChild(privacyHubCameraSubpage);
+ flush();
+ });
+
+ teardown(() => {
+ privacyHubCameraSubpage.remove();
+ Router.getInstance().resetRouteForTesting();
+ });
+
+ function getCameraCrToggle(): CrToggleElement {
+ const crToggle =
+ privacyHubCameraSubpage.shadowRoot!.querySelector<CrToggleElement>(
+ '#cameraToggle');
+ assertTrue(!!crToggle);
+ return crToggle;
+ }
+
+ function getOnOffText(): string {
+ return privacyHubCameraSubpage.shadowRoot!.querySelector('#onOffText')!
+ .textContent!.trim();
+ }
+
+ function getOnOffSubtext(): string {
+ return privacyHubCameraSubpage.shadowRoot!.querySelector('#onOffSubtext')!
+ .textContent!.trim();
+ }
+
+ function isCameraListSectionVisible(): boolean {
+ return isVisible(privacyHubCameraSubpage.shadowRoot!.querySelector(
+ '#cameraListSection'));
+ }
+
+ function getNoCameraTextElement(): HTMLDivElement|null {
+ return privacyHubCameraSubpage.shadowRoot!.querySelector('#noCameraText');
+ }
+
+ function getCameraList(): DomRepeat|null {
+ return privacyHubCameraSubpage.shadowRoot!.querySelector<DomRepeat>(
+ '#cameraList');
+ }
+
+ test('Camera section view when access is enabled', () => {
+ const cameraToggle = getCameraCrToggle();
+
+ assertTrue(cameraToggle.checked);
+ assertEquals(privacyHubCameraSubpage.i18n('deviceOn'), getOnOffText());
+ assertEquals(
+ privacyHubCameraSubpage.i18n('cameraToggleSubtext'), getOnOffSubtext());
+ assertTrue(isCameraListSectionVisible());
+ });
+
+ test('Camera section view when access is disabled', async () => {
+ mediaDevices.addDevice('videoinput', 'Fake Camera');
+ await flushTasks();
+
+ const cameraToggle = getCameraCrToggle();
+
+ // Disable camera access.
+ cameraToggle.click();
+ flush();
+
+ assertFalse(cameraToggle.checked);
+ assertEquals(privacyHubCameraSubpage.i18n('deviceOff'), getOnOffText());
+ assertEquals(
+ privacyHubCameraSubpage.i18n('blockedForAllText'), getOnOffSubtext());
+ assertFalse(isCameraListSectionVisible());
+ });
+
+ test('Repeatedly toggle camera access', async () => {
+ mediaDevices.addDevice('videoinput', 'Fake Camera');
+ await flushTasks();
+
+ const cameraToggle = getCameraCrToggle();
+
+ for (let i = 0; i < 3; i++) {
+ cameraToggle.click();
+ flush();
+
+ assertEquals(
+ cameraToggle.checked,
+ privacyHubCameraSubpage.prefs.ash.user.camera_allowed.value);
+ }
+ });
+
+ test('No camera connected and toggle disabled by default', () => {
+ assertTrue(getCameraCrToggle().disabled);
+ assertNull(getCameraList());
+ assertTrue(!!getNoCameraTextElement());
+ assertEquals(
+ privacyHubCameraSubpage.i18n('noCameraConnectedText'),
+ getNoCameraTextElement()!.textContent!.trim());
+ });
+
+ test('Change force-disable-camera-switch', async () => {
+ mediaDevices.addDevice('videoinput', 'Fake Camera');
+ await flushTasks();
+
+ assertFalse(getCameraCrToggle().disabled);
+
+ webUIListenerCallback('force-disable-camera-switch', true);
+ await flushTasks();
+
+ assertTrue(getCameraCrToggle().disabled);
+
+ webUIListenerCallback('force-disable-camera-switch', false);
+ await flushTasks();
+
+ assertFalse(getCameraCrToggle().disabled);
+ });
+
+ test('Toggle enabled when at least one camera connected', async () => {
+ mediaDevices.addDevice('videoinput', 'Fake Camera');
+ await flushTasks();
+
+ assertFalse(getCameraCrToggle().disabled);
+ assertNull(getNoCameraTextElement());
+ });
+
+ test('Camera list updated when a camera is added or removed', async () => {
+ const testDevices = [
+ {
+ device: {
+ kind: 'audiooutput',
+ label: 'Fake Speaker 1',
+ },
+ },
+ {
+ device: {
+ kind: 'videoinput',
+ label: 'Fake Camera 1',
+ },
+ },
+ {
+ device: {
+ kind: 'audioinput',
+ label: 'Fake Microphone 1',
+ },
+ },
+ {
+ device: {
+ kind: 'videoinput',
+ label: 'Fake Camera 2',
+ },
+ },
+ {
+ device: {
+ kind: 'audiooutput',
+ label: 'Fake Speaker 2',
+ },
+ },
+ {
+ device: {
+ kind: 'audioinput',
+ label: 'Fake Microphone 2',
+ },
+ },
+ ];
+
+ let cameraCount = 0;
+
+ // Adding a media device in each iteration.
+ for (const test of testDevices) {
+ mediaDevices.addDevice(test.device.kind, test.device.label);
+ await flushTasks();
+
+ if (test.device.kind === 'videoinput') {
+ cameraCount++;
+ }
+
+ const cameraList = getCameraList();
+ if (cameraCount) {
+ assertTrue(!!cameraList);
+ assertEquals(cameraCount, cameraList.items!.length);
+ } else {
+ assertNull(cameraList);
+ }
+ }
+
+ // Removing the most recently added media device in each iteration.
+ for (const test of testDevices.reverse()) {
+ mediaDevices.popDevice();
+ await flushTasks();
+
+ if (test.device.kind === 'videoinput') {
+ cameraCount--;
+ }
+
+ const cameraList = getCameraList();
+ if (cameraCount) {
+ assertTrue(!!cameraList);
+ assertEquals(cameraCount, cameraList.items!.length);
+ } else {
+ assertNull(cameraList);
+ }
+ }
+ });
+});
diff --git a/chrome/test/data/webui/settings/chromeos/os_privacy_page/privacy_hub_microphone_subpage_test.ts b/chrome/test/data/webui/settings/chromeos/os_privacy_page/privacy_hub_microphone_subpage_test.ts
index 63bdf55..ee2bdd9 100644
--- a/chrome/test/data/webui/settings/chromeos/os_privacy_page/privacy_hub_microphone_subpage_test.ts
+++ b/chrome/test/data/webui/settings/chromeos/os_privacy_page/privacy_hub_microphone_subpage_test.ts
@@ -89,7 +89,7 @@
'#microphoneListSection'));
}
- function getNoMicrophoneText(): HTMLDivElement|null {
+ function getNoMicrophoneTextElement(): HTMLDivElement|null {
return privacyHubMicrophoneSubpage.shadowRoot!.querySelector(
'#noMicrophoneText');
}
@@ -135,7 +135,9 @@
.value);
assertEquals(
privacyHubMicrophoneSubpage.i18n('deviceOff'), getOnOffText());
- assertEquals('Blocked for all', getOnOffSubtext());
+ assertEquals(
+ privacyHubMicrophoneSubpage.i18n('blockedForAllText'),
+ getOnOffSubtext());
assertFalse(isMicrophoneListSectionVisible());
});
@@ -159,10 +161,10 @@
test('No microphone connected by default', () => {
assertNull(getMicrophoneList());
- assertTrue(!!getNoMicrophoneText());
+ assertTrue(!!getNoMicrophoneTextElement());
assertEquals(
privacyHubMicrophoneSubpage.i18n('noMicrophoneConnectedText'),
- getNoMicrophoneText()!.textContent!.trim());
+ getNoMicrophoneTextElement()!.textContent!.trim());
});
test(
@@ -181,7 +183,7 @@
assertFalse(getMicrophoneCrToggle()!.disabled);
assertTrue(getMicrophoneTooltip()!.hidden);
- assertNull(getNoMicrophoneText());
+ assertNull(getNoMicrophoneTextElement());
});
test(
diff --git a/chrome/test/data/webui/settings/chromeos/os_settings_browsertest.js b/chrome/test/data/webui/settings/chromeos/os_settings_browsertest.js
index 1b717bdd0..6c10db9 100644
--- a/chrome/test/data/webui/settings/chromeos/os_settings_browsertest.js
+++ b/chrome/test/data/webui/settings/chromeos/os_settings_browsertest.js
@@ -902,6 +902,16 @@
'os_privacy_page/privacy_hub_app_permission_row_test.js'
],
[
+ 'OsPrivacyPagePrivacyHubCameraSubpage',
+ 'os_privacy_page/privacy_hub_camera_subpage_test.js',
+ {
+ enabled: [
+ 'ash::features::kCrosPrivacyHubV0',
+ 'ash::features::kCrosPrivacyHubAppPermissions'
+ ]
+ },
+ ],
+ [
'OsPrivacyPagePrivacyHubMicrophoneSubpage',
'os_privacy_page/privacy_hub_microphone_subpage_test.js',
{