// Copyright 2020 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, checkEnumVariant} from './assert.js';
import {showPreviewOCRToast} from './custom_effect.js';
import * as dom from './dom.js';
import {reportError} from './error.js';
import {I18nString} from './i18n_string.js';
import {
BarcodeContentType,
OcrEventType,
sendBarcodeDetectedEvent,
sendOcrEvent,
sendUnsupportedProtocolEvent,
} from './metrics.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 {
OcrResult,
WifiConfig,
WifiEapMethod,
WifiEapPhase2Method,
WifiSecurityType,
} from './mojo/type.js';
import {isTopMostView} from './nav.js';
import * as snackbar from './snackbar.js';
import {speak} from './spoken_msg.js';
import * as state from './state.js';
import {OneShotTimer} from './timer.js';
import {
ErrorLevel,
ErrorType,
LocalStorageKey,
ViewName,
} from './type.js';
import {getKeyboardShortcut} from './util.js';
// Supported source types.
export enum Source {
BARCODE = 'BARCODE',
OCR = 'OCR',
}
interface ChipMethods {
// Hide the chip element.
hide(): void;
// Focus the chip. The element receiving focus depends on the type of chip.
focus(): void;
// Returns if the chip is expanded.
isExpanded?(): boolean;
// Returns if the chip is focused inside.
isFocused?(): boolean;
}
interface CurrentChip extends ChipMethods {
// The detected string that is being shown currently.
content: string;
// The type of scanner that detected the content.
source: Source;
// The countdown timer for dismissing the chip.
timer: OneShotTimer;
}
let currentChip: CurrentChip|null = null;
export enum SupportedWifiSecurityType {
EAP = 'WPA2-EAP',
NONE = 'nopass',
WEP = 'WEP',
WPA = 'WPA',
}
const QR_CODE_ESCAPE_CHARS = ['\\', ';', ',', ':'];
// TODO(b/172879638): Tune the duration according to the final motion spec.
const CHIP_DURATION = 8000;
// Screen reader users may take longer to read content. We treat keyboard users
// as screen reader users since we can't tell the difference. This is the
// maximum possible delay for setTimeout, preventing the timeout from firing.
const CHIP_DURATION_KEYBOARD = 2 ** 31 - 1;
/**
* Checks whether a string is a regular url link with http or https protocol.
*/
function isSafeUrl(s: string): boolean {
try {
const url = new URL(s);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
reportError(
ErrorType.UNSUPPORTED_PROTOCOL, ErrorLevel.WARNING,
new Error(`Reject url with protocol: ${url.protocol}`));
sendUnsupportedProtocolEvent();
return false;
}
return true;
} catch (e) {
return false;
}
}
/**
* Parses the given string `s`. If the string is a wifi connection request,
* return `WifiConfig` and if not, return null.
*/
function parseWifi(s: string): WifiConfig|null {
let securityType: SupportedWifiSecurityType|null =
SupportedWifiSecurityType.NONE;
let ssid = null;
let password = null;
let eapMethod = null;
let anonIdentity = null;
let identity = null;
let phase2method = null;
// Example string `WIFI:S:<SSID>;P:<PASSWORD>;T:<WPA|WEP|WPA2-EAP|nopass>;H;;`
// Reference:
// https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11
if (s.startsWith('WIFI:') && s.endsWith(';;')) {
s = s.substring(5, s.length - 1);
let i = 0;
let component = '';
while (i < s.length) {
// Unescape characters escaped with a backslash
if (s[i] === '\\' && i + 1 < s.length &&
QR_CODE_ESCAPE_CHARS.includes(s[i + 1])) {
component += s[i + 1];
i += 2;
} else if (s[i] === ';') {
const splitIdx = component.search(':');
if (splitIdx === -1) {
return null;
}
const key = component.substring(0, splitIdx);
const val = component.substring(splitIdx + 1);
switch (key) {
case 'A':
anonIdentity = val;
break;
case 'E':
eapMethod = val;
break;
case 'H':
if (val !== 'true' && val !== 'false') {
phase2method = val;
}
break;
case 'I':
identity = val;
break;
case 'P':
password = val;
break;
case 'PH2':
phase2method = val;
break;
case 'S':
ssid = val;
break;
case 'T':
securityType = checkEnumVariant(SupportedWifiSecurityType, val);
sendBarcodeDetectedEvent(
{contentType: BarcodeContentType.WIFI}, val);
break;
default:
return null;
}
component = '';
i += 1;
} else {
component += s[i];
i += 1;
}
}
}
if (ssid === null) {
return null;
}
if (securityType === null) {
return null;
} else if (securityType === SupportedWifiSecurityType.NONE) {
return {
ssid: ssid,
security: WifiSecurityType.kNone,
password: null,
eapMethod: null,
eapPhase2Method: null,
eapIdentity: null,
eapAnonymousIdentity: null,
};
} else if (password === null) {
return null;
} else if (securityType === SupportedWifiSecurityType.WEP) {
return {
ssid: ssid,
security: WifiSecurityType.kWep,
password: password,
eapMethod: null,
eapPhase2Method: null,
eapIdentity: null,
eapAnonymousIdentity: null,
};
} else if (securityType === SupportedWifiSecurityType.WPA) {
return {
ssid: ssid,
security: WifiSecurityType.kWpa,
password: password,
eapMethod: null,
eapPhase2Method: null,
eapIdentity: null,
eapAnonymousIdentity: null,
};
} else if (
eapMethod !== null && anonIdentity !== null && identity !== null &&
phase2method !== null) {
const wifiEapMethod = strToWifiEapMethod(eapMethod);
const wifiEapPhase2method = strToWifiEapPhase2Method(phase2method);
if (wifiEapMethod !== null && wifiEapPhase2method !== null) {
return {
ssid: ssid,
security: WifiSecurityType.kEap,
password: password,
eapMethod: wifiEapMethod,
eapPhase2Method: wifiEapPhase2method,
eapIdentity: identity,
eapAnonymousIdentity: anonIdentity,
};
}
}
return null;
}
/**
* Converts `eapMethod` to supporting WifiEapMethod. If the type is not
* supported, return null.
*/
function strToWifiEapMethod(eapMethod: string): WifiEapMethod|null {
if (eapMethod === 'TLS') {
return WifiEapMethod.kEapTls;
} else if (eapMethod === 'TTLS') {
return WifiEapMethod.kEapTtls;
} else if (eapMethod === 'LEAP') {
return WifiEapMethod.kLeap;
} else if (eapMethod === 'PEAP') {
return WifiEapMethod.kPeap;
}
return null;
}
/**
* Converts `phase2method` to supporting WifiEapPhase2Method. If the type is not
* supported, return null.
*/
function strToWifiEapPhase2Method(phase2method: string): WifiEapPhase2Method|
null {
if (phase2method === 'CHAP') {
return WifiEapPhase2Method.kChap;
} else if (phase2method === 'GTC') {
return WifiEapPhase2Method.kGtc;
} else if (phase2method === 'MD5') {
return WifiEapPhase2Method.kMd5;
} else if (phase2method === 'MSCHAP') {
return WifiEapPhase2Method.kMschap;
} else if (phase2method === 'MSCHAPv2') {
return WifiEapPhase2Method.kMschapv2;
} else if (phase2method === 'PAP') {
return WifiEapPhase2Method.kPap;
} else if (phase2method === 'Automatic') {
return WifiEapPhase2Method.kAutomatic;
}
return null;
}
/**
* Creates the copy button.
*
* TODO(b/311592341): Rename related strings and classes since they are used by
* both barcode and OCR.
*
* @param container The container for the button.
* @param content The content to be copied.
* @param snackbarLabel The label to be displayed on snackbar when the content
* is copied.
* @param onCopy Called when the user clicks the copy button.
*/
function createCopyButton(
container: HTMLElement, content: string, snackbarLabel: I18nString,
onCopy?: () => void): HTMLElement {
const copyButton =
dom.getFrom(container, '.barcode-copy-button', HTMLButtonElement);
copyButton.onclick = async () => {
await navigator.clipboard.writeText(content);
speak(I18nString.COPIED_DETECTED_CONTENT, content);
snackbar.show(snackbarLabel);
onCopy?.();
};
return copyButton;
}
/**
* Shows an actionable url chip.
*/
function showUrl(url: string): ChipMethods {
const container = dom.get('#barcode-chip-url-container', HTMLDivElement);
container.classList.remove('invisible');
const textEl = dom.get('#barcode-chip-url-content', HTMLSpanElement);
textEl.textContent =
loadTimeData.getI18nMessage(I18nString.BARCODE_LINK_CHIPTEXT, url);
const chip = dom.get('#barcode-chip-url', HTMLButtonElement);
chip.onclick = () => {
ChromeHelper.getInstance().openUrlInBrowser(url);
};
const copyButton =
createCopyButton(container, url, I18nString.SNACKBAR_LINK_COPIED);
const label =
loadTimeData.getI18nMessage(I18nString.BARCODE_COPY_LINK_BUTTON, url);
copyButton.setAttribute('aria-label', label);
return {
hide() {
container.classList.add('invisible');
},
focus() {
copyButton.focus();
},
};
}
/**
* Shows an actionable text chip.
*
* By default, the chip only shows a one-line preview of text. The chip can be
* expanded to show the full content if the text is too long.
*
* TODO(b/311592341): Rename related strings and classes since they are used by
* both barcode and OCR.
*/
function showText(text: string, onCopy?: () => void): ChipMethods {
const container = dom.get('#barcode-chip-text-container', HTMLDivElement);
const expandEl = dom.get('#barcode-chip-text-expand', HTMLButtonElement);
const descriptionEl = dom.get('#barcode-chip-text-description', HTMLElement);
function isChipExpanded() {
return container.classList.contains('expanded');
}
function hideChip() {
container.classList.add('invisible');
}
function expandChip() {
container.classList.add('expanded');
expandEl.ariaExpanded = 'true';
expandEl.setAttribute(
'aria-label',
loadTimeData.getI18nMessage(
I18nString.LABEL_COLLAPSE_DETECTED_CONTENT_BUTTON));
}
function collapseChip() {
container.classList.remove('expanded');
expandEl.ariaExpanded = 'false';
expandEl.setAttribute(
'aria-label',
loadTimeData.getI18nMessage(
I18nString.LABEL_EXPAND_DETECTED_CONTENT_BUTTON));
}
collapseChip();
container.classList.remove('invisible');
const textEl = dom.get('#barcode-chip-text-content', HTMLSpanElement);
textEl.textContent = text;
const expandable = textEl.scrollWidth > textEl.clientWidth;
if (expandable) {
descriptionEl.textContent = loadTimeData.getI18nMessage(
I18nString.TEXT_DETECTED_DESCRIPTION_EXPANDABLE);
expandEl.classList.remove('hidden');
expandEl.onclick = () => {
const chipTimer = assertExists(currentChip).timer;
if (isChipExpanded()) {
collapseChip();
chipTimer.start();
} else {
expandChip();
chipTimer.stop();
}
};
} else {
descriptionEl.textContent =
loadTimeData.getI18nMessage(I18nString.TEXT_DETECTED_DESCRIPTION);
expandEl.classList.add('hidden');
}
const copyButton = createCopyButton(
container, text, I18nString.SNACKBAR_TEXT_COPIED, onCopy);
return {
hide: hideChip,
focus() {
// TODO(b/172879638): There is a race in ChromeVox which will speak the
// focused element twice.
copyButton.focus();
},
isExpanded: isChipExpanded,
isFocused() {
return container.contains(document.activeElement);
},
};
}
/**
* Shows an actionable wifi chip for connecting Wi-fi.
*/
function showWifi(wifiConfig: WifiConfig): ChipMethods {
const container = dom.get('#barcode-chip-wifi-container', HTMLDivElement);
container.classList.remove('invisible');
const ssidString = assertExists(wifiConfig.ssid);
const textEl = dom.get('#barcode-chip-wifi-content', HTMLSpanElement);
const text =
loadTimeData.getI18nMessage(I18nString.BARCODE_WIFI_CHIPTEXT, ssidString);
textEl.textContent = text;
const chip = dom.get('#barcode-chip-wifi', HTMLElement);
const label = loadTimeData.getI18nMessage(
I18nString.LABEL_BARCODE_WIFI_CHIP, ssidString);
chip.setAttribute('aria-label', label);
chip.onclick = () => {
ChromeHelper.getInstance().openWifiDialog(wifiConfig);
};
return {
hide() {
container.classList.add('invisible');
},
focus() {
chip.focus();
},
};
}
/**
* Show an actionable chip for content detected from barcode.
*/
export function showBarcodeContent(content: string): void {
function setupChip() {
let chipMethods: ChipMethods|null = null;
const wifiConfig = parseWifi(content);
if (wifiConfig !== null) {
chipMethods = showWifi(wifiConfig);
} else if (isSafeUrl(content)) {
sendBarcodeDetectedEvent({contentType: BarcodeContentType.URL});
chipMethods = showUrl(content);
} else {
sendBarcodeDetectedEvent({contentType: BarcodeContentType.TEXT});
chipMethods = showText(content);
}
return assertExists(chipMethods);
}
showChip({
setupChip,
content,
source: Source.BARCODE,
});
}
/**
* Show an actionable chip for content detected from OCR.
*/
export function showOcrContent(ocrResult: OcrResult): void {
const content = ocrResult.lines.map((line) => line.text).join('\n');
function setupChip() {
// TODO(b/303584151): Remove the toast around 3 milestones after the feature
// is launched.
if (!localStorage.getBool(LocalStorageKey.PREVIEW_OCR_TOAST_SHOWN)) {
const cameraView = dom.get('#view-camera', HTMLElement);
showPreviewOCRToast(cameraView);
localStorage.set(LocalStorageKey.PREVIEW_OCR_TOAST_SHOWN, true);
}
// TODO(b/311592341): Check if we can show Wifi and URL chip when the source
// is OCR.
return showText(content, () => {
sendOcrEvent({
eventType: OcrEventType.COPY_TEXT,
result: ocrResult,
});
});
}
showChip({
setupChip,
content,
source: Source.OCR,
});
}
interface ShowChipParams {
// Shows the chip and returns methods for controlling the chip.
setupChip(): ChipMethods;
// The detected `content` and `source` of the scanner. They are used to check
// if the same content is being shown.
content: string;
source: Source;
}
/**
* Shows an actionable chip for the string detected from various scanners.
*/
function showChip({setupChip, content, source}: ShowChipParams): void {
const isShowing = currentChip !== null;
if (isShowing) {
assert(currentChip !== null);
// Skip updating the chip if it's expanded.
if (currentChip.isExpanded?.() === true) {
return;
}
// Extend the duration by resetting the timeout if the same content is being
// shown.
if (currentChip.source === source && currentChip.content === content) {
currentChip.timer.resetTimeout();
return;
}
}
dismiss();
const chipMethods = setupChip();
// Only focus on chip when newly shown and camera view is the top view.
if (!isShowing && isTopMostView(ViewName.CAMERA)) {
chipMethods.focus();
}
const chipDuration = state.get(state.State.KEYBOARD_NAVIGATION) ?
CHIP_DURATION_KEYBOARD :
CHIP_DURATION;
const timer = new OneShotTimer(dismiss, chipDuration);
currentChip = {
content,
...chipMethods,
source,
timer,
};
window.addEventListener('keydown', onKeyDown);
}
/**
* Dismisses the current chip if it's being shown.
*/
export function dismiss(): void {
if (currentChip === null) {
return;
}
currentChip.timer.stop();
currentChip.hide();
currentChip = null;
window.removeEventListener('keydown', onKeyDown);
}
function onKeyDown(event: KeyboardEvent) {
const {isFocused} = assertExists(currentChip);
if (isFocused?.() === true && getKeyboardShortcut(event) === 'Escape') {
dismiss();
}
}