blob: 773e2620fcb850e3ef280f57af14b04883d5efee [file] [log] [blame]
// 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 {
getDefaultWindowSize,
} from './app_window.js';
import {assert, assertInstanceof} from './assert.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 * as expert from './expert.js';
import {Flag} from './flag.js';
import {GalleryButton} from './gallerybutton.js';
import {I18nString} from './i18n_string.js';
import {Intent} from './intent.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 {ChromeHelper} from './mojo/chrome_helper.js';
import {DeviceOperator} from './mojo/device_operator.js';
import * as nav from './nav.js';
import {PerfLogger} from './perf.js';
import {preloadImagesList} from './preload_images.js';
import * as state from './state.js';
import * as toast from './toast.js';
import * as tooltip from './tooltip.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 * as timertick from './views/camera/timertick.js';
import {CameraIntent} from './views/camera_intent.js';
import {Dialog} 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';
/**
* The app window instance which is used for communication with Tast tests. For
* non-test sessions, it should be null.
*/
const appWindow = window.appWindow;
/**
* Creates the Camera App main object.
*/
export class App {
private readonly perfLogger: PerfLogger;
private readonly intent: Intent|null;
private readonly cameraManager: CameraManager;
private readonly galleryButton = new GalleryButton();
private readonly cameraView: Camera;
constructor({perfLogger, intent, facing, mode}: {
perfLogger: PerfLogger,
intent: Intent|null,
facing: Facing|null,
mode: Mode|null,
}) {
this.perfLogger = perfLogger;
this.intent = intent;
const shouldHandleIntentResult = this.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,
};
this.cameraManager =
new CameraManager(this.perfLogger, facing, modeConstraints);
this.cameraView = (() => {
if (shouldHandleIntentResult) {
// If shouldHandleIntentResult is true, then this.intent is definitely
// not null.
assert(this.intent !== null);
return new CameraIntent(
this.intent, this.cameraManager, this.perfLogger);
} else {
return new Camera(
this.galleryButton, this.cameraManager, this.perfLogger);
}
})();
document.body.addEventListener(
'keydown', (event) => this.onKeyPressed(event));
// 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.addListener(() => nav.layoutShownViews());
util.setupI18nElements(document.body);
this.setupToggles();
localStorage.cleanup();
this.setupEffect();
this.setupExperimentalFeatures();
// Set up views navigation by their DOM z-order.
nav.setup([
this.cameraView,
new Warning(),
new Dialog(ViewName.MESSAGE_DIALOG),
new View(ViewName.SPLASH),
]);
nav.open(ViewName.SPLASH);
}
/**
* Sets up toggles (checkbox and radio) by data attributes.
*/
private setupToggles() {
for (const element of dom.getAll('input', HTMLInputElement)) {
element.addEventListener('keypress', (event) => {
const e = assertInstanceof(event, KeyboardEvent);
if (util.getKeyboardShortcut(e) === 'Enter') {
element.click();
}
});
const localStorageKey = element.dataset['key'] === undefined ?
null :
util.assertEnumVariant(LocalStorageKey, element.dataset['key']);
const stateKey = element.dataset['state'] === undefined ?
null :
state.assertState(element.dataset['state']);
function save(element: HTMLInputElement) {
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);
}
});
}
if (localStorageKey !== null) {
const value = localStorage.getBool(localStorageKey, element.checked);
util.toggleChecked(element, value);
}
}
}
/**
* Sets up visual effect for all applicable elements.
*/
private 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,
});
}
private setupExperimentalFeatures() {
if (loadTimeData.getChromeFlag(Flag.TIME_LAPSE)) {
const modeButton = dom.get('#time-lapse-mode', HTMLDivElement);
modeButton.classList.remove('hidden');
}
}
/**
* Starts the app by loading the model and opening the camera-view.
*/
async start(launchType: metrics.LaunchType): Promise<void> {
await DeviceOperator.initializeInstance();
document.documentElement.dir = loadTimeData.getTextDirection();
try {
await filesystem.initialize();
const cameraDir = filesystem.getCameraDirectory();
assert(cameraDir !== null);
// 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)
// Only (1) and (2) will show gallery button on the UI.
if (this.intent === null || !this.intent.shouldHandleResult) {
this.galleryButton.initialize(cameraDir);
}
} catch (error) {
reportError(ErrorType.FILE_SYSTEM_FAILURE, ErrorLevel.ERROR, error);
nav.open(ViewName.WARNING, WarningType.FILESYSTEM_FAILURE);
}
const showWindow = (async () => {
// For intent only requiring open camera with specific mode without
// returning the capture result, finish it directly.
if (this.intent !== null && !this.intent.shouldHandleResult) {
this.intent.finish();
}
})();
const cameraResourceInitialized = new WaitableEvent();
const exploitUsage = async () => {
if (cameraResourceInitialized.isSignaled()) {
this.resume();
} else {
// CCA must get camera usage for completing its initialization when
// first launched.
await this.cameraManager.initialize(this.cameraView);
await this.cameraView.initialize();
cameraResourceInitialized.signal();
}
};
const releaseUsage = async () => {
assert(cameraResourceInitialized.isSignaled());
await this.suspend();
};
await ChromeHelper.getInstance().initCameraUsageMonitor(
exploitUsage, releaseUsage);
const startCamera = (async () => {
await cameraResourceInitialized.wait();
const isSuccess = await this.cameraManager.requestResume();
if (isSuccess) {
const {aspectRatio} = this.cameraManager.getPreviewResolution();
const {width, height} = getDefaultWindowSize(aspectRatio);
window.resizeTo(width, height);
}
nav.close(ViewName.SPLASH);
nav.open(ViewName.CAMERA);
const windowCreationTime = window.windowCreationTime;
this.perfLogger.start(
PerfEvent.LAUNCHING_FROM_WINDOW_CREATION, windowCreationTime);
this.perfLogger.stop(
PerfEvent.LAUNCHING_FROM_WINDOW_CREATION, {hasError: !isSuccess});
if (appWindow !== null) {
appWindow.onAppLaunched();
}
})();
preloadImages();
metrics.sendLaunchEvent({launchType});
await Promise.all([showWindow, startCamera]);
}
/**
* Handles pressed keys.
*
* @param event Key press event.
*/
private onKeyPressed(event: Event) {
tooltip.hide(); // Hide shown tooltip on any keypress.
nav.onKeyPressed(assertInstanceof(event, KeyboardEvent));
}
/**
* Suspends app and hides app window.
*/
async suspend(): Promise<void> {
timertick.cancel();
await this.cameraManager.requestSuspend();
nav.open(ViewName.WARNING, WarningType.CAMERA_PAUSED);
}
/**
* Resumes app from suspension and shows app window.
*/
resume(): void {
this.cameraManager.requestResume();
nav.close(ViewName.WARNING, WarningType.CAMERA_PAUSED);
}
/**
* Begins to take photo or recording with the current options, e.g. timer.
*
* @param shutterType The shutter is triggered by which shutter type.
* @return Promise resolved when take action completes.
* Returns null if CCA can't start take action.
*/
beginTake(shutterType: metrics.ShutterType): Promise<void>|null {
return this.cameraView.beginTake(shutterType);
}
}
/**
* Parse search params in URL.
*/
function parseSearchParams(): {
intent: Intent|null,
facing: Facing|null,
mode: Mode|null,
openFrom: string|null,
autoTake: boolean,
} {
const url = new URL(window.location.href);
const params = url.searchParams;
const facing = util.checkEnumVariant(Facing, params.get('facing'));
const mode = util.checkEnumVariant(Mode, params.get('mode'));
const intent = (() => {
if (params.get('intentId') === null) {
return null;
}
assert(mode !== null);
return Intent.create(url, mode);
})();
const autoTake = params.get('autoTake') === '1';
const openFrom = params.get('openFrom');
return {intent, facing, mode, autoTake, openFrom};
}
/**
* 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 = `/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);
}
/**
* Singleton of the App object.
*/
let instance: App|null = null;
/**
* Creates the App object and starts camera stream.
*/
(async () => {
if (instance !== null) {
return;
}
const perfLogger = new PerfLogger();
const {intent, facing, mode, autoTake, openFrom} = parseSearchParams();
state.set(state.State.INTENT, intent !== null);
addUnloadCallback(() => {
// 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).
if (appWindow !== null) {
appWindow.notifyClosed();
}
});
metrics.initMetrics();
if (appWindow !== null) {
metrics.setMetricsEnabled(false);
}
// Setup listener for performance events.
perfLogger.addListener(({event, duration, perfInfo}) => {
metrics.sendPerfEvent({event, duration, perfInfo});
// Setup for console perf logger.
if (expert.isEnabled(expert.ExpertOption.PRINT_PERFORMANCE_LOGS)) {
// eslint-disable-next-line no-console
console.log(
'%c%s %s ms %s', 'color: #4E4F97; font-weight: bold;',
event.padEnd(40), duration.toFixed(0).padStart(4),
JSON.stringify(perfInfo));
}
// Setup for Tast tests logger.
if (appWindow !== null) {
appWindow.reportPerf({event, duration, perfInfo});
}
});
state.addObserver(state.State.TAKING, (val, extras) => {
// 'taking' state indicates either taking photo or video. Skips for
// video-taking case since we only want to collect the metrics of
// photo-taking.
if (state.get(Mode.VIDEO)) {
return;
}
const event = PerfEvent.PHOTO_TAKING;
if (val) {
perfLogger.start(event);
} else {
perfLogger.stop(event, extras);
}
});
const states = Object.values(PerfEvent);
for (const event of states) {
state.addObserver(event, (val, extras) => {
if (val) {
perfLogger.start(event);
} else {
perfLogger.stop(event, extras);
}
});
}
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})`);
}
instance = new App({perfLogger, intent, facing, mode});
await instance.start(
openFrom === 'assistant' ? metrics.LaunchType.ASSISTANT :
metrics.LaunchType.DEFAULT);
if (autoTake) {
const takePromise = instance.beginTake(
openFrom === 'assistant' ? metrics.ShutterType.ASSISTANT :
metrics.ShutterType.UNKNOWN);
if (takePromise === null) {
toast.show(
mode === Mode.VIDEO ? I18nString.ERROR_MSG_RECORD_START_FAILED :
I18nString.ERROR_MSG_TAKE_PHOTO_FAILED);
} else {
await takePromise;
}
}
})();