chromium/ash/webui/camera_app_ui/resources/js/main.ts

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './lit/components/index.js';

import {
  ColorChangeUpdater,
} from
    'chrome://resources/cr_components/color_change_listener/colors_css_updater.js';

import {
  getDefaultWindowSize,
} from './app_window.js';
import {
  assert,
  assertEnumVariant,
  assertExists,
  assertInstanceof,
  checkEnumVariant,
} from './assert.js';
import * as customEffect from './custom_effect.js';
import {DEPLOYED_VERSION} from './deployed_version.js';
import {CameraManager} from './device/index.js';
import {ModeConstraints} from './device/type.js';
import * as dom from './dom.js';
import {reportError} from './error.js';
import {Flag} from './flag.js';
import {Intent} from './intent.js';
import * as Comlink from './lib/comlink.js';
import {startMeasuringMemoryUsage} from './memory_usage.js';
import * as metrics from './metrics.js';
import * as filesystem from './models/file_system.js';
import * as loadTimeData from './models/load_time_data.js';
import * as localStorage from './models/local_storage.js';
import {DefaultResultSaver} from './models/result_saver.js';
import {ChromeHelper} from './mojo/chrome_helper.js';
import {DeviceOperator} from './mojo/device_operator.js';
import {WindowStateType} from './mojo/type.js';
import {WindowInstance} from './multi_window_manager.js';
import * as nav from './nav.js';
import {PerfLogger} from './perf.js';
import {preloadImagesList} from './preload_images.js';
import {preloadSounds} from './sound.js';
import * as state from './state.js';
import * as toast from './toast.js';
import * as tooltip from './tooltip.js';
import {getSanitizedScriptUrl} from './trusted_script_url_policy_util.js';
import {
  ErrorLevel,
  ErrorType,
  Facing,
  LocalStorageKey,
  Mode,
  PerfEvent,
  ViewName,
} from './type.js';
import {addUnloadCallback} from './unload.js';
import * as util from './util.js';
import {Camera} from './views/camera.js';
import {toggleIndicatorOnOpenPTZButton} from './views/camera/options.js';
import * as timertick from './views/camera/timertick.js';
import {CameraIntent} from './views/camera_intent.js';
import {SuperResIntroDialog} from './views/dialog.js';
import {View} from './views/view.js';
import {Warning, WarningType} from './views/warning.js';
import {WaitableEvent} from './waitable_event.js';
import {windowController} from './window_controller.js';

/**
 * Sets up tooltips for elements having `i18n-label` attribute. This method
 * also setup tooltips for the elements:
 * * Added to the DOM and have a `i18n-label` attribute.
 * * Newly set with a `i18n-label` attribute.
 *
 * Note `i18n-label` attribute should not be removed from elements.
 */
function setupTooltip() {
  tooltip.init();
  const tooltipAttribute = 'i18n-label';
  const tooltipAttributeSelector = `[${tooltipAttribute}]`;
  const elements =
      Array.from(dom.getAll(tooltipAttributeSelector, HTMLElement));
  tooltip.setupElements(elements);
  const observer = new MutationObserver((mutations) => {
    const elements: HTMLElement[] = [];
    for (const mutation of mutations) {
      if (mutation.type === 'attributes') {
        // Check newly added attributes on existing elements.
        const {target, oldValue} = mutation;
        if (target instanceof HTMLElement && oldValue === null) {
          elements.push(target);
        }
      } else if (mutation.type === 'childList') {
        // Check newly added nodes.
        for (const node of mutation.addedNodes) {
          if (node instanceof HTMLElement) {
            if (node.hasAttribute(tooltipAttribute)) {
              elements.push(node);
            }
            elements.push(
                ...dom.getAllFrom(node, tooltipAttributeSelector, HTMLElement));
          }
        }
      }
    }
    tooltip.setupElements(elements);
  });
  observer.observe(document.body, {
    subtree: true,
    childList: true,
    attributeFilter: [tooltipAttribute],
    attributes: true,
    attributeOldValue: true,
  });
}

/**
 * Sets up toggles (checkbox and radio) by data attributes.
 */
function setupToggles() {
  for (const element of dom.getAll('input', HTMLInputElement)) {
    element.addEventListener('keypress', (event) => {
      if (util.getKeyboardShortcut(event) === 'Enter') {
        element.click();
      }
    });
    function getKey(element: HTMLInputElement) {
      return element.dataset['key'] === undefined ?
          null :
          assertEnumVariant(LocalStorageKey, element.dataset['key']);
    }
    const stateKey = element.dataset['state'] === undefined ?
        null :
        state.assertState(element.dataset['state']);

    function save(element: HTMLInputElement) {
      const localStorageKey = getKey(element);
      if (localStorageKey !== null) {
        localStorage.set(localStorageKey, element.checked);
      }
    }
    element.addEventListener('change', (event) => {
      if (stateKey !== null) {
        state.set(stateKey, element.checked);
      }
      // Check if event is triggered by user on UI.
      if (event.isTrusted) {
        save(element);
        if (element.type === 'radio' && element.checked) {
          // Handle unchecked grouped sibling radios.
          const grouped =
              `input[type=radio][name=${element.name}]:not(:checked)`;
          for (const radio of dom.getAll(grouped, HTMLInputElement)) {
            radio.dispatchEvent(new Event('change'));
            save(radio);
          }
        }
      }
    });
    if (stateKey !== null) {
      state.set(stateKey, element.checked);
      state.addObserver(stateKey, (value) => {
        if (value !== element.checked) {
          util.toggleChecked(element, value);
        }
      });
    }
    const localStorageKey = getKey(element);
    if (localStorageKey !== null) {
      const value = localStorage.getBool(localStorageKey, element.checked);
      util.toggleChecked(element, value);
    }
  }
}

/**
 * Sets up visual effect for all applicable elements.
 */
function setupEffect() {
  for (const el of dom.getAll('.inkdrop', HTMLElement)) {
    util.setInkdropEffect(el);
  }

  const observer = new MutationObserver((mutationList) => {
    for (const mutation of mutationList) {
      assert(mutation.type === 'childList');
      // Only the newly added nodes with inkdrop class are considered here. So
      // simply adding class attribute on existing element will not work.
      for (const node of mutation.addedNodes) {
        if (!(node instanceof HTMLElement)) {
          continue;
        }
        if (node.classList.contains('inkdrop')) {
          util.setInkdropEffect(node);
        }
      }
    }
  });
  observer.observe(document.body, {
    subtree: true,
    childList: true,
  });
}

/**
 * Handles pressed keys.
 */
function onKeyPressed(event: KeyboardEvent) {
  tooltip.hide();  // Hide shown tooltip on any keypress.
  nav.onKeyPressed(event);
}

/**
 * Parse search params in URL.
 */
function parseSearchParams(): {
  intent: Intent|null,
  facing: Facing|null,
  mode: Mode|null,
} {
  const url = new URL(window.location.href);
  const params = url.searchParams;

  const facing = checkEnumVariant(Facing, params.get('facing'));

  const mode = checkEnumVariant(Mode, params.get('mode'));

  const intent = (() => {
    if (params.get('intentId') === null) {
      return null;
    }
    assert(mode !== null);
    return Intent.create(url, mode);
  })();

  return {intent, facing, mode};
}

/**
 * Preload images to avoid flickering.
 */
function preloadImages() {
  const imagesContainer = document.createElement('div');
  imagesContainer.id = 'preload-images';
  imagesContainer.hidden = true;
  for (const imageName of preloadImagesList) {
    const img = document.createElement('img');
    const url = util.expandPath(`/images/${imageName}`);
    img.onerror = () => {
      reportError(
          ErrorType.PRELOAD_IMAGE_FAILURE, ErrorLevel.ERROR,
          new Error(`Failed to preload image ${url}`));
    };
    img.src = url;
    imagesContainer.appendChild(img);
  }
  document.body.appendChild(imagesContainer);
}

/**
 * Append dynamic color CSS files and setup watcher for color changes.
 */
async function setupDynamicColor(): Promise<void> {
  function loadCSS(url: string): Promise<void> {
    return new Promise((resolve) => {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = url;
      link.addEventListener('load', () => resolve());
      document.head.appendChild(link);
    });
  }
  ColorChangeUpdater.forDocument().start();
  // Note that this has to be loaded after
  // ColorChangeUpdater.forDocument.start() is called, since we override the
  // force dark theme on the color_change_listener BindInterface in
  // camera_app_ui.cc
  // TODO(pihsun): Check if there's way to override color scheme earlier before
  // HTML load, so the CSS can be put into .html file instead of being injected
  // by JS.
  await loadCSS('chrome://theme/colors.css?sets=ref,sys');
}

async function setupMultiWindowHandling(
    cameraManager: CameraManager, cameraView: Camera,
    cameraResourceInitialized: WaitableEvent): Promise<void> {
  async function handleResume() {
    try {
      if (cameraResourceInitialized.isSignaled()) {
        await cameraManager.requestResume();
        nav.close(ViewName.WARNING, WarningType.CAMERA_PAUSED);
      } else {
        // CCA must get camera usage for completing its initialization when
        // first launched.
        await cameraManager.initialize(cameraView);
        cameraView.initialize();
        cameraResourceInitialized.signal();
      }
    } catch (e) {
      reportError(
          ErrorType.RESUME_CAMERA_FAILURE, ErrorLevel.ERROR,
          assertInstanceof(e, Error));
    }
  }
  async function handleSuspend() {
    try {
      assert(cameraResourceInitialized.isSignaled());
      timertick.cancel();
      await cameraManager.requestSuspend();
      nav.open(ViewName.WARNING, WarningType.CAMERA_PAUSED);
    } catch (e) {
      reportError(
          ErrorType.SUSPEND_CAMERA_FAILURE, ErrorLevel.ERROR,
          assertInstanceof(e, Error));
    }
  }

  const multiWindowManagerWorker = new SharedWorker(
      getSanitizedScriptUrl('/js/multi_window_manager.js'), {type: 'module'});
  const windowInstance =
      Comlink.wrap<WindowInstance>(multiWindowManagerWorker.port);
  addUnloadCallback(() => {
    windowInstance.onWindowClosed().catch((e) => {
      reportError(
          ErrorType.MULTI_WINDOW_HANDLING_FAILURE, ErrorLevel.ERROR,
          assertInstanceof(e, Error));
    });
  });
  await windowInstance.init(
      Comlink.proxy(handleSuspend), Comlink.proxy(handleResume));
  await ChromeHelper.getInstance().initCameraWindowController();
  windowController.addWindowStateListener((states) => {
    const isMinimizing = states.includes(WindowStateType.kMinimized);
    // If the window is minimized while recording time-lapse, the camera
    // usage will not be paused to keep recording.
    if (isMinimizing && state.get(state.State.RECORDING) &&
        state.get(state.State.RECORD_TYPE_TIME_LAPSE)) {
      return;
    }
    windowInstance.onVisibilityChanged(!isMinimizing).catch((e) => {
      reportError(
          ErrorType.MULTI_WINDOW_HANDLING_FAILURE, ErrorLevel.ERROR,
          assertInstanceof(e, Error));
    });
  });
  windowController.addWindowFocusListener((isFocused) => {
    // If we change the focus to another CCA window, it should get the camera
    // ownership.
    if (isFocused) {
      windowInstance.onVisibilityChanged(true).catch((e) => {
        reportError(
            ErrorType.MULTI_WINDOW_HANDLING_FAILURE, ErrorLevel.ERROR,
            assertInstanceof(e, Error));
      });
    }
  });
}

function setupSvgs() {
  for (const el of dom.getAll('[data-svg]', HTMLElement)) {
    const imageName = assertExists(el.dataset['svg']);
    const svg = document.createElement('svg-wrapper');
    svg.setAttribute('name', imageName);
    // Prepend the svg so it's on the bottom-most layer and won't be covering
    // other possible children (e.g. inkdrop effect).
    el.prepend(svg);
  }
}

function maybeIntroduceSuperRes() {
  // Only introduce the feature when both digital zoom and super res flags are
  // enabled for the first time.
  if (!loadTimeData.getChromeFlag(Flag.DIGITAL_ZOOM) ||
      !loadTimeData.getChromeFlag(Flag.SUPER_RES) ||
      localStorage.getBool(LocalStorageKey.SUPER_RES_DIALOG_SHOWN) ||
      window.isInTestSession) {
    return;
  }
  nav.open(ViewName.SUPER_RES_INTRO_DIALOG);
  toggleIndicatorOnOpenPTZButton(true);
  localStorage.set(LocalStorageKey.SUPER_RES_DIALOG_SHOWN, true);
}

/**
 * Setup Camera App and starts camera stream.
 */
async function main() {
  const {intent, facing, mode} = parseSearchParams();

  state.set(state.State.INTENT, intent !== null);

  addUnloadCallback(async () => {
    // For SWA, we don't cancel the unhandled intent here since there is no
    // guarantee that asynchronous calls in unload listener can be executed
    // properly. Therefore, we moved the logic for canceling unhandled intent to
    // Chrome (CameraAppHelper).
    await window.appWindow?.notifyClosed();
  });

  // metrics.ts handle it's ready state inside the module, and we don't want to
  // block CCA by metrics initialization.
  void metrics.initMetrics();
  if (window.appWindow !== null) {
    // Disable metrics when in testing.
    void metrics.setEnabled(false);
  }

  // toast and splash style depends on dynamic color css being imported.
  await setupDynamicColor();

  if (DEPLOYED_VERSION !== undefined) {
    // eslint-disable-next-line no-console
    console.log(
        `Local override enabled for CCA (${DEPLOYED_VERSION}). ` +
        'To disable local override, ' +
        'remove /etc/camera/cca/js/deployed_version.js on device.');
    toast.showDebugMessage(`Local override enabled (${DEPLOYED_VERSION})`);
  }

  // There are three possible cases:
  // 1. Regular instance
  //      (intent === null)
  // 2. STILL_CAPTURE_CAMERA and VIDEO_CAMERA intents
  //      (intent !== null && shouldHandleResult === false)
  // 3. Other intents
  //      (intent !== null && shouldHandleResult === true)
  // `shouldHandleIntentResult` will be false in (1) and (2), and gallery
  // button will be shown on the UI.
  const shouldHandleIntentResult = intent?.shouldHandleResult === true;
  state.set(state.State.SHOULD_HANDLE_INTENT_RESULT, shouldHandleIntentResult);

  const modeConstraints: ModeConstraints = {
    kind: shouldHandleIntentResult && mode !== null ? 'exact' : 'default',
    mode: mode ?? Mode.PHOTO,
  };

  PerfLogger.initializeInstance();
  const cameraManager = new CameraManager(facing, modeConstraints);

  const resultSaver = new DefaultResultSaver();

  const cameraView = shouldHandleIntentResult ?
      new CameraIntent(intent, cameraManager) :
      new Camera(resultSaver, cameraManager);

  // Set up views navigation by their DOM z-order.
  nav.setup([
    cameraView,
    new SuperResIntroDialog(),
    new Warning(),
    new View(ViewName.SPLASH),
  ]);

  nav.open(ViewName.SPLASH);

  document.documentElement.dir = loadTimeData.getTextDirection();
  // Disable the zoom in-out gesture which is triggered by wheel and pinch on
  // trackpad.
  document.body.addEventListener('wheel', (event) => {
    if (event.ctrlKey) {
      event.preventDefault();
    }
  }, {passive: false, capture: true});

  window.addEventListener('resize', () => nav.layoutShownViews());
  windowController.addWindowStateListener(() => nav.layoutShownViews());

  customEffect.setup();
  util.setupI18nElements(document.body);
  setupTooltip();
  setupToggles();
  localStorage.cleanup();
  setupEffect();
  preloadImages();
  preloadSounds();
  setupSvgs();

  await DeviceOperator.initializeInstance();

  // Create a promise to finish the intent, that runs in parallel with starting
  // camera.
  const finishIntent = (async () => {
    // For intent only requiring open camera with specific mode without
    // returning the capture result, finish it directly.
    if (intent !== null && !intent.shouldHandleResult) {
      await intent.finish();
    }
  })();

  const cameraResourceInitialized = new WaitableEvent();
  await setupMultiWindowHandling(
      cameraManager, cameraView, cameraResourceInitialized);

  // Key handler (in particular, back button) depends on windowController being
  // initialized by setupMultiWindowHandling.
  document.body.addEventListener('keydown', (event) => onKeyPressed(event));

  metrics.sendLaunchEvent({launchType: metrics.LaunchType.DEFAULT});

  await cameraResourceInitialized.wait();
  const cameraStartSuccessful = await cameraManager.reconfigure();

  try {
    await filesystem.initialize();
    const cameraDir = filesystem.getCameraDirectory();
    if (!shouldHandleIntentResult) {
      await resultSaver.initialize(cameraDir);
    }
  } catch (error) {
    reportError(ErrorType.FILE_SYSTEM_FAILURE, ErrorLevel.ERROR, error);
    nav.open(ViewName.WARNING, WarningType.FILESYSTEM_FAILURE);
  }

  // To align window behavior with other apps, defaultWindowSize is only applied
  // when the camera app is first opened. Later, the window will be opened in
  // the size that a user prefers.
  if (cameraStartSuccessful &&
      localStorage.getBool(LocalStorageKey.FIRST_OPENING, true)) {
    const {aspectRatio} = cameraManager.getPreviewResolution();
    const {width, height} = getDefaultWindowSize(aspectRatio);
    window.resizeTo(width, height);
    localStorage.set(LocalStorageKey.FIRST_OPENING, false);
  }

  // Waits for the intent to finish before switching to main camera view.
  // TODO(pihsun): Check if the performance gain for running this in parallel
  // is significant, and simplify this by inlining the promise if it isn't.
  await finishIntent;

  nav.close(ViewName.SPLASH);
  nav.open(ViewName.CAMERA);

  const perfLogger = PerfLogger.getInstance();
  perfLogger.start(
      PerfEvent.LAUNCHING_FROM_WINDOW_CREATION, window.windowCreationTime);
  perfLogger.stop(
      PerfEvent.LAUNCHING_FROM_WINDOW_CREATION,
      {hasError: !cameraStartSuccessful});

  maybeIntroduceSuperRes();

  // Start the memory measurement when the camera preview is ready. The first
  // measurement is performed immediately. The following measurements are
  // performed periodically, or triggered by specific behaviors.
  startMeasuringMemoryUsage();

  await window.appWindow?.onAppLaunched();
  metrics.sendOpenCameraEvent(cameraManager.getVidPid());
}

// This is the entry point of CCA so the returned promise is not awaited.
void main();