chromium/chrome/browser/resources/chromeos/accessibility/accessibility_common/facegaze/facegaze.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 {TestImportManager} from '/common/testing/test_import_manager.js';
import type {FaceLandmarkerResult} from '/third_party/mediapipe/vision.js';

import {FaceGazeConstants} from './constants.js';
import {GestureHandler} from './gesture_handler.js';
import {MetricsUtils} from './metrics_utils.js';
import {MouseController} from './mouse_controller.js';
import {FaceLandmarkerResultWithLatency, WebCamFaceLandmarker} from './web_cam_face_landmarker.js';

type PrefObject = chrome.settingsPrivate.PrefObject;

/** Main class for FaceGaze. */
export class FaceGaze {
  private mouseController_: MouseController;
  private gestureHandler_: GestureHandler;
  private onInitCallbackForTest_: (() => void)|undefined;
  private initialized_ = false;
  private cursorControlEnabled_ = false;
  private actionsEnabled_ = false;
  private prefsListener_: (prefs: PrefObject[]) => void;
  private metricsUtils_: MetricsUtils;
  private webCamFaceLandmarker_: WebCamFaceLandmarker;
  private skipInitializeWebCamFaceLandmarkerForTesting_ = false;
  private weightsWindowId_ = -1;

  constructor() {
    this.webCamFaceLandmarker_ = new WebCamFaceLandmarker(
        (resultWithLatency: FaceLandmarkerResultWithLatency) => {
          const {result, latency} = resultWithLatency;
          this.processFaceLandmarkerResult_(result, latency);
        });

    this.mouseController_ = new MouseController();
    this.gestureHandler_ = new GestureHandler(this.mouseController_);
    this.metricsUtils_ = new MetricsUtils();
    this.prefsListener_ = prefs => this.updateFromPrefs_(prefs);
    this.init_();
  }

  /** Initializes FaceGaze. */
  private init_(): void {
    // TODO(b/309121742): Listen to magnifier bounds changed so as to update
    // cursor relative position logic when magnifier is running.

    chrome.settingsPrivate.getAllPrefs(prefs => this.updateFromPrefs_(prefs));
    chrome.settingsPrivate.onPrefsChanged.addListener(this.prefsListener_);

    if (this.onInitCallbackForTest_) {
      this.onInitCallbackForTest_();
      this.onInitCallbackForTest_ = undefined;
    }
    this.initialized_ = true;

    chrome.settingsPrivate.getPref(
        FaceGaze.PREF_ACCELERATOR_DIALOG_HAS_BEEN_ACCEPTED, pref => {
          if (pref.value === undefined || pref.value === null) {
            return;
          }

          if (pref.value) {
            // If the confirmation dialog has already been accepted, there is no
            // need to show it again. We can proceed as if it's been accepted.
            this.onConfirmationDialog_(true);
            return;
          }

          // If the confirmation dialog has not been accepted yet, display it to
          // the user.
          const title =
              chrome.i18n.getMessage('facegaze_confirmation_dialog_title');
          const description =
              chrome.i18n.getMessage('facegaze_confirmation_dialog_desc');
          chrome.accessibilityPrivate.showConfirmationDialog(
              title, description, /*cancelName=*/ undefined, (accepted) => {
                this.onConfirmationDialog_(accepted);
              });
        });
  }

  /** Runs when the confirmation dialog has either been accepted or rejected. */
  private onConfirmationDialog_(accepted: boolean): void {
    chrome.settingsPrivate.setPref(
        FaceGaze.PREF_ACCELERATOR_DIALOG_HAS_BEEN_ACCEPTED, accepted);
    if (!accepted) {
      // If the dialog was rejected, then disable the FaceGaze feature.
      chrome.settingsPrivate.setPref(FaceGaze.PREF_FACE_GAZE_ENABLED, false);
      return;
    }

    // If the dialog was accepted, then initialize FaceGaze.
    this.openWeightsPanel_();
    chrome.accessibilityPrivate.openSettingsSubpage(
        FaceGaze.SETTINGS_PAGE_ROUTE);

    // Use a timeout to defer the initialization of the WebCamFaceLandmarker.
    // For tests, we can guard the initialization using a testing-specific
    // variable. For production, this will initialize the WebCamFaceLandmarker
    // after the timeout has elapsed.
    setTimeout(() => {
      this.maybeInitializeWebCamFaceLandmarker_();
    }, FaceGaze.INITIALIZE_WEB_CAM_FACE_LANDMARKER_TIMEOUT);
  }

  private maybeInitializeWebCamFaceLandmarker_(): void {
    if (this.skipInitializeWebCamFaceLandmarkerForTesting_) {
      return;
    }

    this.webCamFaceLandmarker_.init();
  }

  private updateFromPrefs_(prefs: PrefObject[]): void {
    prefs.forEach(pref => {
      switch (pref.key) {
        case FaceGaze.PREF_CURSOR_CONTROL_ENABLED:
          this.cursorControlEnabledChanged_(pref.value);
          break;
        case FaceGaze.PREF_ACTIONS_ENABLED:
          this.actionsEnabledChanged_(pref.value);
          break;
        default:
          return;
      }
    });
  }

  private cursorControlEnabledChanged_(value: boolean): void {
    if (this.cursorControlEnabled_ === value) {
      return;
    }
    this.cursorControlEnabled_ = value;
    if (this.cursorControlEnabled_) {
      this.mouseController_.start();
    } else {
      this.mouseController_.stop();
    }
  }

  private actionsEnabledChanged_(value: boolean): void {
    if (this.actionsEnabled_ === value) {
      return;
    }
    this.actionsEnabled_ = value;
    if (this.actionsEnabled_) {
      this.gestureHandler_.start();
    } else {
      this.gestureHandler_.stop();
    }
  }

  private processFaceLandmarkerResult_(
      result: FaceLandmarkerResult, latency?: number): void {
    if (!result) {
      return;
    }

    if (latency !== undefined) {
      this.metricsUtils_.addFaceLandmarkerResultLatency(latency);
    }

    if (this.cursorControlEnabled_) {
      this.mouseController_.onFaceLandmarkerResult(result);
    }

    if (this.actionsEnabled_) {
      const macros = this.gestureHandler_.detectMacros(result);
      for (const macro of macros) {
        const checkContextResult = macro.checkContext();
        if (!checkContextResult.canTryAction) {
          console.warn(
              'Cannot execute macro in this context', macro.getName(),
              checkContextResult.error, checkContextResult.failedContext);
          continue;
        }
        const runMacroResult = macro.run();
        if (!runMacroResult.isSuccess) {
          console.warn(
              'Failed to execute macro ', macro.getName(),
              runMacroResult.error);
        }
      }
    }
  }

  /** Destructor to remove any listeners. */
  onFaceGazeDisabled(): void {
    this.mouseController_.reset();
    this.gestureHandler_.stop();
    if (this.weightsWindowId_ !== -1) {
      chrome.windows.remove(this.weightsWindowId_);
    }
  }

  /** Allows tests to wait for FaceGaze to be fully initialized. */
  setOnInitCallbackForTest(callback: () => void): void {
    if (!this.initialized_) {
      this.onInitCallbackForTest_ = callback;
      return;
    }

    callback();
  }

  /**
   * Used to set the value of `skipInitializeWebCamFaceLandmarkerForTesting_`.
   * We want to use this method in tests because tests will want to avoid
   * initializing the WebCamFaceLandmarker, since it starts the webcam stream
   * and causes errors.
   */
  setSkipInitializeWebCamFaceLandmarkerForTesting(skip: boolean): void {
    this.skipInitializeWebCamFaceLandmarkerForTesting_ = skip;
  }

  private openWeightsPanel_(): void {
    const params = {
      url: chrome.runtime.getURL('accessibility_common/facegaze/weights.html'),
      type: chrome.windows.CreateType.PANEL,
    };
    chrome.windows.create(params, (win) => {
      if (!win || win.id === undefined) {
        return;
      }

      this.weightsWindowId_ = win.id;
      chrome.runtime.onMessage.addListener(message => {
        if (message.type === FaceGazeConstants.UPDATE_LANDMARK_WEIGHTS) {
          this.mouseController_.updateLandmarkWeights(
              new Map(Object.entries(message.weights)));
        }

        return false;
      });
    });
  }
}

export namespace FaceGaze {
  export const INITIALIZE_WEB_CAM_FACE_LANDMARKER_TIMEOUT = 5 * 1000;
  // Pref names. Should be in sync with with values at ash_pref_names.h.
  export const PREF_ACCELERATOR_DIALOG_HAS_BEEN_ACCEPTED =
      'settings.a11y.face_gaze.accelerator_dialog_has_been_accepted';
  export const PREF_FACE_GAZE_ENABLED = 'settings.a11y.face_gaze.enabled';
  export const PREF_ACTIONS_ENABLED = 'settings.a11y.face_gaze.actions_enabled';
  export const PREF_CURSOR_CONTROL_ENABLED =
      'settings.a11y.face_gaze.cursor_control_enabled';

  export const SETTINGS_PAGE_ROUTE = 'manageAccessibility/faceGaze';
}

TestImportManager.exportForTesting(FaceGaze);