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

import './privacy_hub_app_permission_row.js';
import 'chrome://resources/ash/common/cr_elements/cr_radio_button/cr_radio_button_style.css.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import '../controls/controlled_button.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_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 {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {OpenWindowProxyImpl} from 'chrome://resources/js/open_window_proxy.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {assertExhaustive, castExists} from '../assert_extras.js';
import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {isSecondaryUser} from '../common/load_time_booleans.js';
import {RouteObserverMixin} from '../common/route_observer_mixin.js';
import {SettingsDropdownMenuElement} from '../controls/settings_dropdown_menu.js';
import {SettingsRadioGroupElement} from '../controls/settings_radio_group.js';
import {App, AppPermissionsHandlerInterface, AppPermissionsObserverReceiver} from '../mojom-webui/app_permission_handler.mojom-webui.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';

import {getAppPermissionProvider} from './mojo_interface_provider.js';
import {PrivacyHubBrowserProxy, PrivacyHubBrowserProxyImpl} from './privacy_hub_browser_proxy.js';
import {getTemplate} from './privacy_hub_geolocation_subpage.html.js';
import {LOCATION_PERMISSION_CHANGE_FROM_SETTINGS_HISTOGRAM_NAME} from './privacy_hub_metrics_util.js';

/**
 * Geolocation access levels for the ChromeOS system.
 * This must be kept in sync with `GeolocationAccessLevel` in
 * ash/constants/geolocation_access_level.h
 */
export enum GeolocationAccessLevel {
  DISALLOWED = 0,
  ALLOWED = 1,
  ONLY_ALLOWED_FOR_SYSTEM = 2,

  MAX_VALUE = ONLY_ALLOWED_FOR_SYSTEM,
}

export enum ScheduleType {
  NONE = 0,
  SUNSET_TO_SUNRISE = 1,
  CUSTOM = 2,
}
/**
 * Whether the app has location permission defined.
 */
function hasLocationPermission(app: App): boolean {
  return app.permissions[PermissionType.kLocation] !== undefined;
}

export interface SettingsPrivacyHubGeolocationSubpage {
  $: {
    geolocationDropdown: SettingsDropdownMenuElement,
  };
}

const SettingsPrivacyHubGeolocationSubpageBase =
    RouteObserverMixin(DeepLinkingMixin(PrefsMixin(I18nMixin(PolymerElement))));

export class SettingsPrivacyHubGeolocationSubpage extends
    SettingsPrivacyHubGeolocationSubpageBase {
  static get is() {
    return 'settings-privacy-hub-geolocation-subpage' as const;
  }

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

  static get properties() {
    return {
      geolocationAccessLevelPrefValues_: {
        readOnly: true,
        type: Object,
        value: {
          DISALLOWED: GeolocationAccessLevel.DISALLOWED,
          ALLOWED: GeolocationAccessLevel.ALLOWED,
          ONLY_ALLOWED_FOR_SYSTEM:
              GeolocationAccessLevel.ONLY_ALLOWED_FOR_SYSTEM,
        },
      },

      /**
       * Apps with location permission defined.
       */
      appList_: {
        type: Array,
        value: [],
      },
      automaticTimeZoneText_: {
        type: String,
        computed: 'computeAutomaticTimeZoneText_(' +
            'prefs.ash.user.geolocation_access_level.value,' +
            'prefs.generated.resolve_timezone_by_geolocation_on_off.value,' +
            'currentTimeZoneName_)',
      },
      isSecondaryUser_: {
        type: Boolean,
        value() {
          return isSecondaryUser();
        },
        readOnly: true,
      },
      isGeolocationAllowedForApps_: {
        type: Boolean,
        computed: 'computedIsGeolocationAllowedForApps_(' +
            'prefs.ash.user.geolocation_access_level.value)',
      },
      currentTimeZoneName_: {
        type: String,
        notify: true,
      },
      currentSunRiseTime_: {
        type: String,
        notify: true,
      },
      currentSunSetTime_: {
        type: String,
        notify: true,
      },
      nightLightText_: {
        type: String,
        computed: 'computeNightLightText_(' +
            'prefs.ash.user.geolocation_access_level.value,' +
            `prefs.ash.night_light.schedule_type.value,` +
            'currentSunRiseTime_, currentSunSetTime_)',
      },
      darkThemeText_: {
        type: String,
        computed: 'computeDarkThemeText_(' +
            'prefs.ash.user.geolocation_access_level.value,' +
            `prefs.ash.dark_mode.schedule_type.value,` +
            'currentSunRiseTime_, currentSunSetTime_)',
      },
      localWeatherText_: {
        type: String,
        computed: 'computeLocalWeatherText_(' +
            'prefs.ash.user.geolocation_access_level.value,' +
            'prefs.settings.ambient_mode.enabled.value)',
      },
    };
  }

  static get observers() {
    return [
      'onTimeZoneChanged_(prefs.cros.system.timezone.value)',
    ];
  }

  override currentRouteChanged(route: Route): void {
    // Does not apply to this page.
    if (route !== routes.PRIVACY_HUB_GEOLOCATION_ADVANCED) {
      return;
    }

    this.attemptDeepLink();
  }

  /**
   * Used by DeepLinkingMixin to focus this page's deep links.
   */
  override supportedSettingIds = new Set([
    Setting.kGeolocationAdvanced,
  ]);

  private geolocationAccessLevel_: string;
  private geolocationAccessLevelPrefValues_: {[key: string]: number};
  private geolocationModeDescriptionText_: string;
  private appList_: App[];
  private appPermissionsObserverReceiver_: AppPermissionsObserverReceiver|null;
  private isSecondaryUser_: boolean;
  private isGeolocationAllowedForApps_: boolean;
  private mojoInterfaceProvider_: AppPermissionsHandlerInterface;
  private browserProxy_: PrivacyHubBrowserProxy;
  private currentTimeZoneName_: string;
  private currentSunRiseTime_: string;
  private currentSunSetTime_: string;
  private shouldShowManageGeolocationDialog_: boolean;

  constructor() {
    super();

    this.mojoInterfaceProvider_ = getAppPermissionProvider();

    this.appPermissionsObserverReceiver_ = null;
    this.browserProxy_ = PrivacyHubBrowserProxyImpl.getInstance();
    // Assigning the initial time zone name.
    this.currentTimeZoneName_ = this.i18n('timeZoneName');
    this.currentSunRiseTime_ =
        this.i18n('privacyHubSystemServicesInitSunRiseTime');
    this.currentSunSetTime_ =
        this.i18n('privacyHubSystemServicesInitSunSetTime');
  }

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

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

    this.updateAppList_();
  }

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

  private isGeolocationPrefEnforced_(): boolean {
    return this.getPref('ash.user.geolocation_access_level').enforcement ===
        chrome.settingsPrivate.Enforcement.ENFORCED;
  }

  private isGeolocationModifiable_(): boolean {
    if (isSecondaryUser() || this.isGeolocationPrefEnforced_()) {
      return false;
    }

    return true;
  }

  private showManageGeolocationDialog_(): void {
    if (this.isGeolocationModifiable_()) {
      this.shouldShowManageGeolocationDialog_ = true;
    }
  }

  private onCancelClicked_(): void {
    const radioGroup: SettingsRadioGroupElement =
        this.shadowRoot!.querySelector<SettingsRadioGroupElement>(
            '#manageGeolocationRadioGroup')!;
    radioGroup.resetToPrefValue();
    this.shouldShowManageGeolocationDialog_ = false;
  }

  private onConfirmClicked_(): void {
    // Reflect user choice to the underlying pref.
    const radioGroup: SettingsRadioGroupElement =
        this.shadowRoot!.querySelector<SettingsRadioGroupElement>(
            '#manageGeolocationRadioGroup')!;
    radioGroup.sendPrefChange();

    // Record metrics.
    this.recordMetric_();

    // Dismiss the dialog.
    this.shouldShowManageGeolocationDialog_ = false;
  }

  private settingControlledByPrimaryUserText_(): string {
    return this.i18n(
        'geolocationControlledByPrimaryUserText',
        loadTimeData.getString('primaryUserEmail'));
  }

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

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

  private isLocationPermissionEnabled_(app: App): boolean {
    const permission = castExists(app.permissions[PermissionType.kLocation]);
    return isPermissionEnabled(permission.value);
  }

  /** Implements AppPermissionsObserver.OnAppUpdated */
  onAppUpdated(updatedApp: App): void {
    if (!hasLocationPermission(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 computedIsGeolocationAllowedForApps_(): boolean {
    const accessLevel: GeolocationAccessLevel =
        this.getPref<GeolocationAccessLevel>(
                'ash.user.geolocation_access_level')
            .value;
    switch (accessLevel) {
      case GeolocationAccessLevel.ALLOWED:
        return true;
      case GeolocationAccessLevel.DISALLOWED:
      case GeolocationAccessLevel.ONLY_ALLOWED_FOR_SYSTEM:
        return false;
      default:
        assertExhaustive(accessLevel);
    }
  }

  private isAutomaticTimeZoneConfigured_(): boolean {
    return this.getPref('generated.resolve_timezone_by_geolocation_on_off')
        .value;
  }

  private computeAutomaticTimeZoneText_(): string {
    if (!this.prefs) {
      return '';
    }

    if (!this.isAutomaticTimeZoneConfigured_()) {
      return this.i18n('privacyHubSystemServicesGeolocationNotConfigured');
    }

    return this.geolocationAllowedForSystem_() ?
        this.i18n('privacyHubSystemServicesAllowedText') :
        this.i18n(
            'privacyHubSystemServicesAutomaticTimeZoneBlockedText',
            this.currentTimeZoneName_);
  }

  private isNightLightConfiguredToUseGeolocation_(): boolean {
    return this.getPref<ScheduleType>('ash.night_light.schedule_type').value ===
        ScheduleType.SUNSET_TO_SUNRISE;
  }

  private computeNightLightText_(): string {
    if (!this.prefs) {
      return '';
    }

    if (!this.isNightLightConfiguredToUseGeolocation_()) {
      return this.i18n('privacyHubSystemServicesGeolocationNotConfigured');
    }

    return this.geolocationAllowedForSystem_() ?
        this.i18n('privacyHubSystemServicesAllowedText') :
        this.i18n(
            'privacyHubSystemServicesSunsetScheduleBlockedText',
            this.currentSunRiseTime_, this.currentSunSetTime_);
  }

  private onManagePermissionsInChromeRowClick_(): void {
    this.mojoInterfaceProvider_.openBrowserPermissionSettings(
        PermissionType.kLocation);
  }

  private computeGeolocationAccessLevelText_(): TrustedHTML {
    const accessLevel: GeolocationAccessLevel =
        this.getPref<GeolocationAccessLevel>(
                'ash.user.geolocation_access_level')
            .value;
    switch (accessLevel) {
      case GeolocationAccessLevel.ALLOWED:
        return this.i18nAdvanced('geolocationAccessLevelAllowed');
      case GeolocationAccessLevel.ONLY_ALLOWED_FOR_SYSTEM:
        return this.i18nAdvanced('geolocationAccessLevelOnlyAllowedForSystem');
      case GeolocationAccessLevel.DISALLOWED:
        return this.i18nAdvanced('geolocationAccessLevelDisallowed');
      default:
        assertExhaustive(accessLevel);
    }
  }

  private computeGeolocationAccessLevelDescriptionText_(): TrustedHTML {
    const accessLevel: GeolocationAccessLevel =
        this.getPref<GeolocationAccessLevel>(
                'ash.user.geolocation_access_level')
            .value;
    switch (accessLevel) {
      case GeolocationAccessLevel.ALLOWED:
        return this.i18nAdvanced('geolocationAllowedModeDescription');
      case GeolocationAccessLevel.ONLY_ALLOWED_FOR_SYSTEM:
        return this.i18nAdvanced(
            'geolocationOnlyAllowedForSystemModeDescription');
      case GeolocationAccessLevel.DISALLOWED:
        return this.i18nAdvanced('geolocationBlockedModeDescription');
      default:
        assertExhaustive(accessLevel);
    }
  }

  private recordMetric_(): void {
    const accessLevel: GeolocationAccessLevel =
        this.getPref<GeolocationAccessLevel>(
                'ash.user.geolocation_access_level')
            .value;

    chrome.metricsPrivate.recordEnumerationValue(
        LOCATION_PERMISSION_CHANGE_FROM_SETTINGS_HISTOGRAM_NAME, accessLevel,
        GeolocationAccessLevel.MAX_VALUE + 1);
  }

  private geolocationAllowedForSystem_(): boolean {
    if (!this.prefs) {
      // Won't show blocked services and apps in case that the geolocation pref
      // is not yet loaded.
      return true;
    }
    return this.getPref<GeolocationAccessLevel>(
                   'ash.user.geolocation_access_level')
               .value !== GeolocationAccessLevel.DISALLOWED;
  }

  private isLocalWeatherConfiguredToUseGeolocation_(): boolean {
    return this.getPref('settings.ambient_mode.enabled').value;
  }

  private computeLocalWeatherText_(): string {
    if (!this.prefs) {
      return '';
    }

    if (!this.isLocalWeatherConfiguredToUseGeolocation_()) {
      return this.i18n('privacyHubSystemServicesGeolocationNotConfigured');
    }

    return this.geolocationAllowedForSystem_() ?
        this.i18n('privacyHubSystemServicesAllowedText') :
        this.i18n('privacyHubSystemServicesBlockedText');
  }

  private isDarkThemeConfiguredToUseGeolocation_(): boolean {
    return this.getPref<ScheduleType>('ash.dark_mode.schedule_type').value ===
        ScheduleType.SUNSET_TO_SUNRISE;
  }

  private computeDarkThemeText_(): string {
    if (!this.prefs) {
      return '';
    }

    if (!this.isDarkThemeConfiguredToUseGeolocation_()) {
      return this.i18n('privacyHubSystemServicesGeolocationNotConfigured');
    }

    return this.geolocationAllowedForSystem_() ?
        this.i18n('privacyHubSystemServicesAllowedText') :
        this.i18n(
            'privacyHubSystemServicesSunsetScheduleBlockedText',
            this.currentSunRiseTime_, this.currentSunSetTime_);
  }

  private onTimeZoneChanged_(): void {
    this.browserProxy_.getCurrentTimeZoneName().then((timeZoneName) => {
      this.currentTimeZoneName_ = timeZoneName;
    });
    this.browserProxy_.getCurrentSunriseTime().then((time) => {
      this.currentSunRiseTime_ = time;
    });
    this.browserProxy_.getCurrentSunsetTime().then((time) => {
      this.currentSunSetTime_ = time;
    });
  }

  private onTimeZoneClick_(): void {
    Router.getInstance().navigateTo(routes.DATETIME_TIMEZONE_SUBPAGE);
  }

  private onNightLightClick_(): void {
    Router.getInstance().navigateTo(routes.DISPLAY);
  }

  private onDarkThemeClick_(): void {
    OpenWindowProxyImpl.getInstance().openUrl(
        loadTimeData.getString('personalizationAppUrl'));
  }

  private onLocalWeatherClick_(): void {
    OpenWindowProxyImpl.getInstance().openUrl(
        loadTimeData.getString('personalizationAppUrl') +
        loadTimeData.getString('ambientSubpageRelativeUrl'));
  }

  private onGeolocationAdvancedAreaClick_(): void {
    Router.getInstance().navigateTo(routes.PRIVACY_HUB_GEOLOCATION_ADVANCED);
  }
}

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

customElements.define(
    SettingsPrivacyHubGeolocationSubpage.is,
    SettingsPrivacyHubGeolocationSubpage);