// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert, assertExists} from './assert.js';
import {Intent} from './intent.js';
import * as Comlink from './lib/comlink.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 * as mojoType from './mojo/type.js';
import * as mojoTypeUtils from './mojo/type_utils.js';
import * as state from './state.js';
import {State} from './state.js';
import {
AspectRatioSet,
Facing,
LocalStorageKey,
Mode,
PerfEvent,
PerfInformation,
PhotoResolutionLevel,
Resolution,
VideoResolutionLevel,
} from './type.js';
import {
Ga4EventParams,
Ga4MetricDimension,
GaBaseEvent,
GaMetricDimension,
getGaHelper,
MemoryUsageEventDimension,
} from './untrusted_scripts.js';
import {WaitableEvent} from './waitable_event.js';
/**
* The tracker ID of the GA metrics and the measurement ID of GA4 events. Make
* sure to set `PRODUCTION` to `false` when developing/debugging metrics. See
* Debugging section in go/cros-camera:dd:cca-ga-migration.
*/
const PRODUCTION = true;
const GA_ID = PRODUCTION ? 'UA-134822711-1' : 'UA-134822711-2';
const GA4_ID = PRODUCTION ? 'G-TRQS261G6E' : 'G-J03LBPJBGD';
const GA4_API_SECRET =
PRODUCTION ? '0Ir88y9HQtiwnchvaIzZ3Q' : 'WE_zBPUQTGefdXpHl25-ig';
const ready = new WaitableEvent();
// This is used to send events via CrOS Events.
let eventsSender: mojoType.EventsSenderRemote|null = null;
/**
* Sends the event to GA backend.
*
* @param event The event to send.
* @param dimensions Optional object contains dimension information.
*/
function sendEvent(
event: GaBaseEvent,
dimensions: Map<GaMetricDimension, string> = new Map()) {
if (event.eventValue !== undefined && !Number.isInteger(event.eventValue)) {
// Round the duration here since GA expects that the value is an
// integer. Reference:
// https://support.google.com/analytics/answer/1033068
event.eventValue = Math.round(event.eventValue);
}
// No caller use the returned promise since metrics sending should not block
// the code.
void (async () => {
await ready.wait();
if (await checkCanSendMetrics()) {
await Promise.all([
sendGaEvent(event, dimensions),
sendGa4Event(event, dimensions),
]);
}
})();
}
async function sendGaEvent(
baseEvent: GaBaseEvent, dimensions: Map<GaMetricDimension, string>) {
const gaHelper = await getGaHelper();
await gaHelper.sendGaEvent({baseEvent, dimensions});
}
async function sendGa4Event(
baseEvent: GaBaseEvent, dimensions: Map<GaMetricDimension, string>) {
const params: Ga4EventParams = {};
if (baseEvent.eventCategory !== undefined) {
params.event_category = baseEvent.eventCategory;
}
if (baseEvent.eventLabel !== undefined) {
params.event_label = baseEvent.eventLabel;
}
if (baseEvent.eventValue !== undefined) {
params.value = baseEvent.eventValue;
}
for (const [gaKey, value] of dimensions) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const ga4Key = GaMetricDimension[gaKey].toLowerCase() as
Lowercase<keyof typeof GaMetricDimension>;
params[ga4Key] = value;
}
const gaHelper = await getGaHelper();
const name = baseEvent.eventAction.replaceAll('-', '_');
await gaHelper.sendGa4Event({name, eventParams: params});
}
/**
* Sets if the metrics is enabled. Note that the metrics will only be sent if it
* is enabled AND the logging consent option is enabled in OS settings.
*
* @param enabled True if the metrics is enabled.
*/
export async function setEnabled(enabled: boolean): Promise<void> {
await ready.wait();
const gaHelper = await getGaHelper();
await Promise.all([
gaHelper.setGaEnabled(GA_ID, enabled),
gaHelper.setGa4Enabled(enabled),
]);
}
/**
* Initializes GA and GA4 with parameters like property ID, client ID, and
* custom dimensions from `loadTimeData`.
*/
export async function initMetrics(): Promise<void> {
const board = assertExists(/^(x86-)?(\w*)/.exec(loadTimeData.getBoard()))[0];
const isTestImage = loadTimeData.getIsTestImage();
const gaHelper = await getGaHelper();
function initGa() {
const baseDimensions = new Map([
[GaMetricDimension.BOARD, board],
[GaMetricDimension.IS_TEST_IMAGE, boolToIntString(isTestImage)],
[GaMetricDimension.OS_VERSION, loadTimeData.getOsVersion()],
]);
const clientId = localStorage.getString(LocalStorageKey.GA_USER_ID);
function setClientId(id: string) {
localStorage.set(LocalStorageKey.GA_USER_ID, id);
}
return gaHelper.initGa(
{
id: GA_ID,
clientId,
baseDimensions,
},
Comlink.proxy(setClientId));
}
function initGa4() {
const baseParams = {
[Ga4MetricDimension.BOARD]: board,
[Ga4MetricDimension.IS_TEST_IMAGE]: boolToIntString(isTestImage),
[Ga4MetricDimension.BROWSER_VERSION]: loadTimeData.getBrowserVersion(),
[Ga4MetricDimension.OS_VERSION]: loadTimeData.getOsVersion(),
};
const clientId = localStorage.getString(LocalStorageKey.GA4_CLIENT_ID);
function setClientId(id: string) {
localStorage.set(LocalStorageKey.GA4_CLIENT_ID, id);
}
return gaHelper.initGa4(
{
apiSecret: GA4_API_SECRET,
baseParams,
clientId,
measurementId: GA4_ID,
},
Comlink.proxy(setClientId));
}
// GA_IDs are refreshed every 90 days cycle according to GA_ID_REFRESH_TIME.
// If GA_ID_REFRESH_TIME does not exist or is outdated, updates
// GA_ID_REFRESH_TIME and removes outdated GA_USER_ID and GA4_CLIENT_ID to
// have the new IDs.
const timeNow = Date.now();
const dayInMs = 1000 * 60 * 60 * 24;
let refreshTime = localStorage.getNumber(LocalStorageKey.GA_ID_REFRESH_TIME);
// Assign the first |refreshTime| uniformly in today+[1,90] days.
// This solves an initial launch problem by avoiding that all Chromebooks do a
// synchronized refresh 90 days after launch.
if (refreshTime === 0) {
const randomInt = Math.floor(Math.random() * 90) + 1;
refreshTime = timeNow + randomInt * dayInMs;
} else if (refreshTime <= timeNow) {
localStorage.remove(LocalStorageKey.GA_USER_ID);
localStorage.remove(LocalStorageKey.GA4_CLIENT_ID);
const cycle = 90 * dayInMs;
const cycleCount = Math.floor((timeNow - refreshTime) / cycle) + 1;
refreshTime += cycle * cycleCount;
}
localStorage.set(LocalStorageKey.GA_ID_REFRESH_TIME, refreshTime);
await Promise.all([initGa(), initGa4()]);
// TODO(b/286511762): Monitor consent option to enable/disable sending
// metrics. Since end_session event is sent when the window is unloaded, we
// don't have time to read the value from `checkCanSendMetrics()`. Currently,
// we check the consent option on register instead of send.
if (await checkCanSendMetrics()) {
await Promise.all([
gaHelper.registerGa4EndSessionEvent(),
gaHelper.registerGa4MemoryUsageEvent(),
]);
}
ready.signal();
}
/**
* Types of different ways to launch CCA.
*/
export enum LaunchType {
DEFAULT = 'default',
}
/**
* Parameters for logging launch event. |launchType| stands for how CCA is
* launched.
*/
export interface LaunchEventParam {
launchType: LaunchType;
}
/**
* Sends launch type event.
*/
export function sendLaunchEvent({launchType}: LaunchEventParam): void {
sendEvent(
{
eventCategory: 'launch',
eventAction: 'start',
eventLabel: '',
},
new Map([
[GaMetricDimension.LAUNCH_TYPE, launchType],
]));
void (async () => {
(await getEventsSender()).sendStartSessionEvent({
launchType: mojoTypeUtils.convertLaunchTypeToMojo(launchType),
});
})();
}
async function getEventsSender(): Promise<mojoType.EventsSenderRemote> {
if (eventsSender === null) {
eventsSender = await ChromeHelper.getInstance().getEventsSender();
}
return eventsSender;
}
/**
* Types of intent result dimension.
*/
export enum IntentResultType {
CANCELED = 'canceled',
CONFIRMED = 'confirmed',
NOT_INTENT = '',
}
/**
* Types of gif recording result dimension.
*/
export enum GifResultType {
NOT_GIF_RESULT = 0,
RETAKE = 1,
SHARE = 2,
SAVE = 3,
}
/**
* Types of recording in video mode.
*/
export enum RecordType {
NOT_RECORDING = 0,
NORMAL_VIDEO = 1,
GIF = 2,
TIME_LAPSE = 3,
}
/**
* Types of different ways to trigger shutter button.
*/
export enum ShutterType {
KEYBOARD = 'keyboard',
MOUSE = 'mouse',
TOUCH = 'touch',
UNKNOWN = 'unknown',
VOLUME_KEY = 'volume-key',
}
/**
* Parameters of capture metrics event.
*/
export interface CaptureEventParam {
/**
* Camera facing of the capture.
*/
facing: Facing;
/**
* Length of duration for captured motion result in milliseconds.
*/
duration?: number;
/**
* Capture resolution.
*/
resolution: Resolution;
intentResult?: IntentResultType;
shutterType: ShutterType;
/**
* Whether the event is for video snapshot.
*/
isVideoSnapshot?: boolean;
/**
* Whether the video have ever paused and resumed in the recording.
*/
everPaused?: boolean;
gifResult?: GifResultType;
recordType?: RecordType;
resolutionLevel: PhotoResolutionLevel|VideoResolutionLevel;
aspectRatioSet: AspectRatioSet;
timeLapseSpeed?: number;
/**
* Zoom ratio when capturing. The value is 1 if zoomed-out, or the camera does
* not support digital zoom.
*/
zoomRatio?: number;
}
/**
* Sends capture type event.
*/
export function sendCaptureEvent({
facing,
duration = 0,
resolution,
intentResult = IntentResultType.NOT_INTENT,
shutterType,
isVideoSnapshot = false,
everPaused = false,
recordType = RecordType.NOT_RECORDING,
gifResult = GifResultType.NOT_GIF_RESULT,
resolutionLevel,
aspectRatioSet,
timeLapseSpeed = 0,
zoomRatio = 1.0,
}: CaptureEventParam): void {
function condState(
states: state.StateUnion[],
cond?: state.StateUnion,
strict = false,
): string {
// Return the first existing state among the given states only if
// there is no gate condition or the condition is met.
const prerequisite = cond === undefined || state.get(cond);
if (!prerequisite) {
return strict ? '' : 'n/a';
}
return states.find((s) => state.get(s)) ?? 'n/a';
}
const mode = condState(Object.values(Mode));
const mirrorState = condState([State.MIRROR]);
const gridType = condState(
[State.GRID_3x3, State.GRID_4x4, State.GRID_GOLDEN], State.GRID);
const timerType =
condState([State.TIMER_3SEC, State.TIMER_10SEC], State.TIMER);
const windowMaximizedState = condState([State.MAX_WND]);
const windowPortraitState = condState([State.TALL]);
const micMutedState = condState([State.MIC], Mode.VIDEO, true);
const fpsType = condState([State.FPS_30, State.FPS_60], Mode.VIDEO, true);
sendEvent(
{
eventCategory: 'capture',
eventAction: mode,
eventLabel: facing,
eventValue: duration,
},
new Map([
// Skips 3rd dimension for obsolete 'sound' state.
[GaMetricDimension.MIRROR, mirrorState],
[GaMetricDimension.GRID, gridType],
[GaMetricDimension.TIMER, timerType],
[GaMetricDimension.MICROPHONE, micMutedState],
[GaMetricDimension.MAXIMIZED, windowMaximizedState],
[GaMetricDimension.TALL_ORIENTATION, windowPortraitState],
[GaMetricDimension.RESOLUTION, resolution.toString()],
[GaMetricDimension.FPS, fpsType],
[GaMetricDimension.INTENT_RESULT, intentResult],
[GaMetricDimension.SHUTTER_TYPE, shutterType],
[GaMetricDimension.IS_VIDEO_SNAPSHOT, boolToIntString(isVideoSnapshot)],
[GaMetricDimension.EVER_PAUSED, boolToIntString(everPaused)],
[GaMetricDimension.RECORD_TYPE, String(recordType)],
[GaMetricDimension.GIF_RESULT, String(gifResult)],
[GaMetricDimension.DURATION, String(duration)],
[GaMetricDimension.RESOLUTION_LEVEL, resolutionLevel],
[GaMetricDimension.ASPECT_RATIO_SET, String(aspectRatioSet)],
[GaMetricDimension.TIME_LAPSE_SPEED, String(timeLapseSpeed)],
[GaMetricDimension.ZOOM_RATIO, zoomRatio.toFixed(1)],
]));
void (async () => {
const captureEvent: mojoType.CaptureEventParams = {
mode: mojoTypeUtils.convertModeToMojo(mode),
facing: mojoTypeUtils.convertFacingToMojo(facing),
isMirrored: mirrorState === State.MIRROR,
gridType: mojoTypeUtils.convertGridTypeToMojo(gridType),
timerType: mojoTypeUtils.convertTimerTypeToMojo(timerType),
shutterType: mojoTypeUtils.convertShutterTypeToMojo(shutterType),
androidIntentResultType:
mojoTypeUtils.convertIntentResultToMojo(intentResult),
isWindowMaximized: windowMaximizedState === State.MAX_WND,
isWindowPortrait: windowPortraitState === State.TALL,
resolutionWidth: resolution.width,
resolutionHeight: resolution.height,
resolutionLevel:
mojoTypeUtils.convertResolutionLevelToMojo(resolutionLevel),
aspectRatioSet: mojoTypeUtils.convertAspectRatioSetToMojo(aspectRatioSet),
captureDetails: null,
zoomRatio,
};
if (mode === Mode.PHOTO || isVideoSnapshot) {
captureEvent.captureDetails = {
photoDetails: {
isVideoSnapshot,
},
};
} else if (mode === Mode.VIDEO) {
const captureDetails = {
videoDetails: {
isMuted: micMutedState === State.MIC,
fps: mojoTypeUtils.convertFpsTypeToMojo(fpsType),
everPaused,
duration,
recordTypeDetails: {},
},
};
let recordTypeDetails = null;
if (recordType === RecordType.NORMAL_VIDEO) {
recordTypeDetails = {normalVideoDetails: {}};
} else if (recordType === RecordType.GIF) {
recordTypeDetails = {
gifVideoDetails: {
gifResultType: mojoTypeUtils.convertGifResultTypeToMojo(gifResult),
},
};
} else if (recordType === RecordType.TIME_LAPSE) {
recordTypeDetails = {
timelapseVideoDetails: {
timelapseSpeed: Math.trunc(timeLapseSpeed),
},
};
}
assert(recordTypeDetails !== null);
captureDetails.videoDetails.recordTypeDetails = recordTypeDetails;
captureEvent.captureDetails = captureDetails;
}
(await getEventsSender()).sendCaptureEvent(captureEvent);
})();
}
/**
* Parameters for logging perf event.
*/
interface PerfEventParam {
/**
* Target event type.
*/
event: PerfEvent;
/**
* Duration of the event in ms.
*/
duration: number;
/**
* Optional information for the event.
*/
perfInfo?: PerfInformation;
}
/**
* Sends perf type event.
*/
export function sendPerfEvent({event, duration, perfInfo = {}}: PerfEventParam):
void {
const resolution = perfInfo.resolution ?? '';
const facing = perfInfo.facing ?? '';
const pageCount = perfInfo.pageCount ?? '';
const pressure = assertExists(perfInfo.pressure);
sendEvent(
{
eventCategory: 'perf',
eventAction: event,
eventLabel: facing,
eventValue: duration,
},
new Map([
[GaMetricDimension.RESOLUTION, `${resolution}`],
[GaMetricDimension.DOC_PAGE_COUNT, `${pageCount}`],
[GaMetricDimension.PRESSURE, `${pressure}`],
]));
void (async () => {
(await getEventsSender()).sendPerfEvent({
eventType: mojoTypeUtils.convertPerfEventTypeToMojo(event),
duration,
facing: mojoTypeUtils.convertFacingToMojo(perfInfo.facing ?? null),
resolutionWidth: perfInfo.resolution?.width ?? 0,
resolutionHeight: perfInfo.resolution?.height ?? 0,
pageCount: perfInfo.pageCount ?? 0,
pressure: mojoTypeUtils.convertPressureToMojo(pressure),
});
})();
}
/**
* See Intent class in intent.js for the descriptions of each field.
*/
export interface IntentEventParam {
intent: Intent;
result: IntentResultType;
}
/**
* Sends intent type event.
*/
export function sendIntentEvent({intent, result}: IntentEventParam): void {
const {mode, shouldHandleResult, shouldDownScale, isSecure} = intent;
sendEvent(
{
eventCategory: 'intent',
eventAction: mode,
eventLabel: result,
},
new Map([
[GaMetricDimension.INTENT_RESULT, result],
[
GaMetricDimension.SHOULD_HANDLE_RESULT,
boolToIntString(shouldHandleResult),
],
[GaMetricDimension.SHOULD_DOWN_SCALE, boolToIntString(shouldDownScale)],
[GaMetricDimension.IS_SECURE, boolToIntString(isSecure)],
]));
void (async () => {
(await getEventsSender()).sendAndroidIntentEvent({
mode: mojoTypeUtils.convertModeToMojo(mode),
shouldHandleResult,
shouldDownscale: shouldDownScale,
isSecure,
});
})();
}
export interface ErrorEventParam {
type: string;
level: string;
errorName: string;
fileName: string;
funcName: string;
lineNo: string;
colNo: string;
}
/**
* Sends error type event.
*/
export function sendErrorEvent(
{type, level, errorName, fileName, funcName, lineNo, colNo}:
ErrorEventParam): void {
sendEvent(
{
eventCategory: 'error',
eventAction: type,
eventLabel: level,
},
new Map([
[GaMetricDimension.ERROR_NAME, errorName],
[GaMetricDimension.FILENAME, fileName],
[GaMetricDimension.FUNC_NAME, funcName],
[GaMetricDimension.LINE_NO, lineNo],
[GaMetricDimension.COL_NO, colNo],
]));
}
/**
* Sends the barcode enabled event.
*/
export function sendBarcodeEnabledEvent(): void {
sendEvent({
eventCategory: 'barcode',
eventAction: 'enable',
});
}
/**
* Types of the decoded barcode content.
*/
export enum BarcodeContentType {
TEXT = 'text',
URL = 'url',
WIFI = 'wifi',
}
interface BarcodeDetectedEventParam {
contentType: BarcodeContentType;
}
/**
* Sends the barcode detected event.
*/
export function sendBarcodeDetectedEvent(
{contentType}: BarcodeDetectedEventParam,
wifiSecurityType: string = ''): void {
sendEvent(
{
eventCategory: 'barcode',
eventAction: 'detect',
eventLabel: contentType,
},
new Map([
[GaMetricDimension.WIFI_SECURITY_TYPE, wifiSecurityType],
]));
void (async () => {
(await getEventsSender()).sendBarcodeDetectedEvent({
contentType: mojoTypeUtils.convertBarcodeContentTypeToMojo(contentType),
wifiSecurityType:
mojoTypeUtils.convertWifiSecurityTypeToMojo(wifiSecurityType),
});
})();
}
/**
* Sends the open ptz panel event.
*/
export function sendOpenPTZPanelEvent(
capabilities: {pan: boolean, tilt: boolean, zoom: boolean}): void {
sendEvent(
{
eventCategory: 'ptz',
eventAction: 'open-panel',
},
new Map([
[GaMetricDimension.SUPPORT_PAN, boolToIntString(capabilities.pan)],
[GaMetricDimension.SUPPORT_TILT, boolToIntString(capabilities.tilt)],
[GaMetricDimension.SUPPORT_ZOOM, boolToIntString(capabilities.zoom)],
]));
void (async () => {
(await getEventsSender()).sendOpenPTZPanelEvent({
supportPan: capabilities.pan,
supportTilt: capabilities.tilt,
supportZoom: capabilities.zoom,
});
})();
}
export enum DocScanFixType {
NONE = 0,
CORNER = 0b1,
ROTATION = 0b10,
}
export enum DocScanResultActionType {
CANCEL = 'cancel',
SAVE_AS_PDF = 'save-as-pdf',
SAVE_AS_PHOTO = 'save-as-photo',
SHARE = 'share',
}
/**
* Sends document scanning result event. The actions will either remove all
* pages (cancel) or generate a file from pages (save/share).
*/
export function sendDocScanResultEvent(
action: DocScanResultActionType,
fixType: DocScanFixType,
fixCount: number,
pageCount: number,
): void {
sendEvent(
{
eventCategory: 'doc-scan',
eventAction: action,
eventValue: fixCount,
},
new Map([
[GaMetricDimension.DOC_FIX_TYPE, String(fixType)],
[GaMetricDimension.DOC_PAGE_COUNT, String(pageCount)],
]));
void (async () => {
(await getEventsSender()).sendDocScanResultEvent({
resultType: mojoTypeUtils.convertDocScanResultTypeToMojo(action),
fixTypesMask: mojoTypeUtils.convertDocScanFixTypeToMojo(fixType),
fixCount,
pageCount,
});
})();
}
export enum DocScanActionType {
ADD_PAGE = 'add-page',
DELETE_PAGE = 'delete-page',
FIX = 'fix',
}
/**
* Sends document scanning event.
*/
export function sendDocScanEvent(action: DocScanActionType): void {
sendEvent({
eventCategory: 'doc-scan',
eventAction: action,
});
void (async () => {
(await getEventsSender()).sendDocScanActionEvent({
actionType: mojoTypeUtils.convertDocScanActionTypeToMojo(action),
});
})();
}
export enum LowStorageActionType {
MANAGE_STORAGE_AUTO_STOP = 'manage-storage-auto-stop',
MANAGE_STORAGE_CANNOT_START = 'manage-storage-cannot-start',
SHOW_AUTO_STOP_DIALOG = 'show-auto-stop-dialog',
SHOW_CANNOT_START_DIALOG = 'show-cannot-start-dialog',
SHOW_WARNING_MSG = 'show-warning-msg',
}
/**
* Sends low-storage handling event.
*/
export function sendLowStorageEvent(action: LowStorageActionType): void {
sendEvent({
eventCategory: 'low-storage',
eventAction: action,
});
void (async () => {
(await getEventsSender()).sendLowStorageActionEvent({
actionType: mojoTypeUtils.convertLowStorageActionTypeToMojo(action),
});
})();
}
function boolToIntString(b: boolean) {
return b ? '1' : '0';
}
// The returned value reflects the logging consent option in OS settings.
async function checkCanSendMetrics(): Promise<boolean> {
return !PRODUCTION ||
await ChromeHelper.getInstance().isMetricsAndCrashReportingEnabled();
}
/**
* Set of Top 20 Popular Camera Peripherals' Module ID from
* go/usb-popularity-study. Since 4 cameras of Sonix have the same module ids,
* they are aggregated to `Cam_Sonix`.
*/
export class PopularCamPeripheralSet {
private readonly moduleIDSet: Set<string>;
constructor() {
this.moduleIDSet = new Set<string>([
'046d:085b', // C925e_Logitech
'046d:0825', // C270_Logitech
'0c45:636b', // Cam_Sonix
'0c45:6366', // VitadeAF_Microdia
'046d:0843', // C930e_Logitech
'046d:082d', // HDProC920_Logitech
'046d:0892', // C920HDPro_Logitech
'046d:08e5', // C920PROHD_Logitech
'05a3:9331', // Cam_ARC
'046d:085e', // BRIOUltraHD_Logitech
'046d:085c', // C922ProStream_Logitech
'1b3f:2002', // 808Camera9_Generalplus
'1d6c:0103', // NexiGoN60FHD_2MUVC
'046d:082c', // HDC615_Logitech
'1778:d021', // VZR_IPEVO
'07ca:313a', // LiveStreamer313_Sunplus
'045e:0810', // LifeCamHD3000_Microsoft
]);
}
has(moduleId: string): boolean {
return this.moduleIDSet.has(moduleId);
}
/**
* Returns the original `moduleId` if it exists in `moduleIDSet`. If not,
* returns 'others'.
*/
getMaskedId(moduleId: string): string {
if (this.moduleIDSet.has(moduleId)) {
return moduleId;
}
return 'others';
}
}
const moduleIDSet = new PopularCamPeripheralSet();
/**
* Sends camera opening event.
*
* @param moduleId Camera Module ID in the format of 8 digits hex string, such
* as abcd:1234.
*/
export function sendOpenCameraEvent(moduleId: string|null): void {
const newModuleId =
moduleId === null ? 'MIPI' : moduleIDSet.getMaskedId(moduleId);
sendEvent(
{
eventCategory: 'open-camera',
eventAction: 'open-camera',
},
new Map([
[GaMetricDimension.CAMERA_MODULE_ID, newModuleId],
]));
const params = {cameraModule: {}};
if (moduleId === null) {
params.cameraModule = {
mipiCamera: {},
};
} else {
params.cameraModule = {
usbCamera: {id: moduleIDSet.has(moduleId) ? moduleId : null},
};
}
void (async () => {
(await getEventsSender()).sendOpenCameraEvent(params);
})();
}
/**
* Sends unsupported protocol event.
*/
export function sendUnsupportedProtocolEvent(): void {
sendEvent({
eventCategory: 'barcode',
eventAction: 'unsupportedProtocol',
});
void (async () => {
(await getEventsSender()).sendUnsupportedProtocolEvent();
})();
}
/**
* Updates the memory usage and session behavior value to untrusted_ga_helpers.
*
* @param updatedValue Updated memory usage dimensions value to be updated.
*/
export function updateMemoryUsageEventDimensions(
updatedValue: MemoryUsageEventDimension): void {
// No caller uses the returned promise.
void (async () => {
const gaHelper = await getGaHelper();
await gaHelper.updateMemoryUsageEventDimensions(updatedValue);
(await getEventsSender()).updateMemoryUsageEventParams({
behaviorsMask: mojoTypeUtils.convertSessionBehaviorToMojo(
updatedValue.sessionBehavior),
memoryUsage: BigInt(updatedValue.memoryUsage),
});
})();
}
export enum OcrEventType {
COPY_TEXT = 'copy-text',
TEXT_DETECTED = 'text-detected',
}
interface OcrEventParams {
eventType: OcrEventType;
result: mojoType.OcrResult;
}
/**
* Sends an OCR event.
*/
export function sendOcrEvent({eventType, result}: OcrEventParams): void {
assert(result.lines.length > 0);
const lineCount = result.lines.length;
const wordCount =
result.lines.reduce((acc, line) => acc + line.words.length, 0);
const isPrimaryLanguage =
getElementsWithMaxOccurrence(result.lines.map((line) => line.language))
// Drop subtags from `navigator.language`. For example, 'en-US'
// becomes 'en'.
.includes(navigator.language.split('-')[0]);
sendEvent(
{
eventCategory: 'ocr',
eventAction: eventType,
},
new Map([
[
GaMetricDimension.IS_PRIMARY_LANGUAGE,
boolToIntString(isPrimaryLanguage),
],
[GaMetricDimension.LINE_COUNT, String(lineCount)],
[GaMetricDimension.WORD_COUNT, String(wordCount)],
]));
void (async () => {
(await getEventsSender()).sendOcrEvent({
eventType: mojoTypeUtils.convertOcrEventTypeToMojo(eventType),
isPrimaryLanguage,
lineCount,
wordCount,
});
})();
}
function getElementsWithMaxOccurrence<T>(elements: T[]) {
const map = new Map<T, number>();
let elementsWithMaxOccurrence: T[] = [];
let maxOccurrence = 0;
for (const element of elements) {
const occurrence = (map.get(element) ?? 0) + 1;
if (maxOccurrence < occurrence) {
maxOccurrence = occurrence;
elementsWithMaxOccurrence = [element];
} else if (occurrence === maxOccurrence) {
elementsWithMaxOccurrence.push(element);
}
map.set(element, occurrence);
}
return elementsWithMaxOccurrence;
}