// 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();