| // 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. |
| |
| /** |
| * @fileoverview Page in eSIM Setup flow that accepts activation code. |
| * User has option for manual entry or scan a QR code. |
| */ |
| import '//resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js'; |
| import '//resources/polymer/v3_0/iron-icon/iron-icon.js'; |
| import '//resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js'; |
| import '//resources/ash/common/cr_elements/cr_input/cr_input.js'; |
| import './base_page.js'; |
| import './cellular_setup_icons.html.js'; |
| |
| import type {CrButtonElement} from '//resources/ash/common/cr_elements/cr_button/cr_button.js'; |
| import {CrInputElement} from '//resources/ash/common/cr_elements/cr_input/cr_input.js'; |
| import {I18nMixin} from '//resources/ash/common/cr_elements/i18n_mixin.js'; |
| import {MojoInterfaceProviderImpl} from '//resources/ash/common/network/mojo_interface_provider.js'; |
| import {assert} from '//resources/js/assert.js'; |
| import {focusWithoutInk} from '//resources/js/focus_without_ink.js'; |
| import {loadTimeData} from '//resources/js/load_time_data.js'; |
| import {CrosNetworkConfigInterface} from '//resources/mojo/chromeos/services/network_config/public/mojom/cros_network_config.mojom-webui.js'; |
| import {NetworkType} from '//resources/mojo/chromeos/services/network_config/public/mojom/network_types.mojom-webui.js'; |
| import {afterNextRender, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import {getTemplate} from './activation_code_page.html.js'; |
| |
| const QR_CODE_DETECTION_INTERVAL_MS = 1000; |
| |
| enum PageState { |
| MANUAL_ENTRY = 1, |
| SCANNING_USER_FACING = 2, |
| SCANNING_ENVIRONMENT_FACING = 3, |
| SWITCHING_CAM_USER_TO_ENVIRONMENT = 4, |
| SWITCHING_CAM_ENVIRONMENT_TO_USER = 5, |
| SCANNING_SUCCESS = 6, |
| SCANNING_FAILURE = 7, |
| MANUAL_ENTRY_INSTALL_FAILURE = 8, |
| SCANNING_INSTALL_FAILURE = 9, |
| } |
| |
| enum UiElement { |
| START_SCANNING = 1, |
| VIDEO = 2, |
| SWITCH_CAMERA = 3, |
| SCAN_FINISH = 4, |
| SCAN_SUCCESS = 5, |
| SCAN_FAILURE = 6, |
| CODE_DETECTED = 7, |
| SCAN_INSTALL_FAILURE = 8, |
| } |
| |
| /** |
| * barcode format used by |BarcodeDetector| |
| */ |
| const QR_CODE_FORMAT = 'qr_code'; |
| |
| /** |
| * The prefix for valid activation codes. |
| */ |
| const ACTIVATION_CODE_PREFIX = 'LPA:1$'; |
| |
| export interface ActivationCodePageElement { |
| $: { |
| activationCode: CrInputElement, |
| }; |
| } |
| |
| const ActivationCodePageElementBase = I18nMixin(PolymerElement); |
| |
| export class ActivationCodePageElement extends ActivationCodePageElementBase { |
| static get is() { |
| return 'activation-code-page' as const; |
| } |
| |
| static get template() { |
| return getTemplate(); |
| } |
| |
| static get properties() { |
| return { |
| activationCode: { |
| type: String, |
| notify: true, |
| observer: 'onActivationCodeChanged_', |
| }, |
| |
| showError: { |
| type: Boolean, |
| notify: true, |
| observer: 'onShowErrorChanged_', |
| }, |
| |
| /** |
| * Readonly property indicating whether the current |activationCode| |
| * was scanned from QR code. |
| */ |
| isFromQrCode: { |
| type: Boolean, |
| notify: true, |
| value: false, |
| }, |
| |
| /** |
| * Indicates no profiles were found while scanning. |
| */ |
| showNoProfilesFound: { |
| type: Boolean, |
| notify: true, |
| }, |
| |
| /** |
| * Enum used as an ID for specific UI elements. |
| * A UiElement is passed between html and JS for |
| * certain UI elements to determine their state. |
| */ |
| UiElement: { |
| type: Object, |
| value: UiElement, |
| }, |
| |
| state_: { |
| type: Object, |
| value: PageState, |
| observer: 'onStateChanged_', |
| }, |
| |
| cameraCount_: { |
| type: Number, |
| value: 0, |
| observer: 'onHasCameraCountChanged_', |
| }, |
| |
| /** |
| * TODO(crbug.com/40134918): add type |BarcodeDetector| when externs |
| * becomes available |
| */ |
| qrCodeDetector_: { |
| type: Object, |
| value: null, |
| }, |
| |
| /** |
| * If true, video is expanded. |
| */ |
| expanded_: { |
| type: Boolean, |
| value: false, |
| reflectToAttribute: true, |
| }, |
| |
| /** |
| * A11y string used to announce the current status of qr code camera |
| * detection. Used when device web cam is turned on and ready to scan, |
| * and also used after scan has been completed. |
| */ |
| qrCodeCameraA11yString_: { |
| type: String, |
| value: '', |
| }, |
| |
| /** |
| * If true, device is locked to specific cellular operator. |
| */ |
| isDeviceCarrierLocked_: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| isCellularCarrierLockEnabled_: { |
| type: Boolean, |
| value() { |
| return loadTimeData.valueExists('isCellularCarrierLockEnabled') && |
| loadTimeData.getBoolean('isCellularCarrierLockEnabled'); |
| }, |
| }, |
| |
| /** |
| * Indicates whether or not |activationCode| matches the correct |
| * activation code format. If there is a partial match (i.e. the code is |
| * incomplete but matches the format so far), this will be false. |
| */ |
| isActivationCodeInvalidFormat_: { |
| type: Boolean, |
| value: false, |
| }, |
| }; |
| } |
| |
| activationCode: string; |
| showError: boolean; |
| isFromQrCode: boolean; |
| showNoProfilesFound: boolean; |
| private state_: PageState; |
| private cameraCount_: number; |
| private qrCodeDetector_: BarcodeDetector|null = null; |
| private expanded_: boolean; |
| private qrCodeCameraA11yString_: string; |
| private isDeviceCarrierLocked_: boolean; |
| private isCellularCarrierLockEnabled_: boolean; |
| private isActivationCodeInvalidFormat_: boolean; |
| private networkConfig_: CrosNetworkConfigInterface|null = null; |
| private mediaDevices_: MediaDevices|null = null; |
| private stream_: MediaStream|null = null; |
| private qrCodeDetectorTimer_: number|null = null; |
| |
| /** |
| * The function used to initiate a repeating timer. Can be overwritten in |
| * tests. |
| */ |
| private setIntervalFunction_: (callback: Function, interval: number) |
| => number = setInterval.bind(window); |
| private barcodeDetectorClass_ = BarcodeDetector; |
| private imageCaptureClass_ = ImageCapture; |
| |
| constructor() { |
| super(); |
| |
| if (!this.isCellularCarrierLockEnabled_) { |
| return; |
| } |
| |
| this.networkConfig_ = |
| MojoInterfaceProviderImpl.getInstance().getMojoServiceRemote(); |
| this.networkConfig_!.getDeviceStateList().then(response => { |
| const devices = response.result; |
| const deviceState = |
| devices.find(device => device.type == NetworkType.kCellular) || null; |
| if (deviceState) { |
| this.isDeviceCarrierLocked_ = deviceState.isCarrierLocked; |
| } |
| }); |
| } |
| |
| override ready() { |
| super.ready(); |
| |
| this.setMediaDevices(navigator.mediaDevices); |
| this.initBarcodeDetector_(); |
| this.state_ = PageState.MANUAL_ENTRY; |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| |
| this.stopStream_(this.stream_); |
| if (this.qrCodeDetectorTimer_) { |
| this.clearQrCodeDetectorTimer_(); |
| } |
| this.mediaDevices_!.removeEventListener( |
| 'devicechange', this.updateCameraCount_.bind(this)); |
| } |
| |
| /** |
| * Function used to play the video. Can be overwritten by |
| * setFakesForTesting(). |
| */ |
| private playVideo_(): void { |
| const videoElement = this.shadowRoot!.querySelector<HTMLVideoElement>('#video'); |
| if (videoElement) { |
| videoElement.play(); |
| } |
| } |
| |
| /** |
| * Function used to stop a stream. Can be overwritten by setFakesForTesting(). |
| */ |
| private stopStream_(stream: MediaStream|null): void { |
| if (stream) { |
| stream.getTracks()[0].stop(); |
| } |
| } |
| |
| private isScanningAvailable_(): boolean { |
| return this.cameraCount_ > 0 && !!this.qrCodeDetector_; |
| } |
| |
| private shouldShowCarrierLockWarning_(): boolean { |
| return this.isCellularCarrierLockEnabled_ && this.isDeviceCarrierLocked_; |
| } |
| |
| /** |
| * TODO(crbug.com/40134918): Remove suppression when shape_detection extern |
| * definitions become available. |
| */ |
| private async initBarcodeDetector_(): Promise<void> { |
| const formats = await this.barcodeDetectorClass_.getSupportedFormats(); |
| |
| if (!formats || formats.length === 0) { |
| this.qrCodeDetector_ = null; |
| return; |
| } |
| |
| const qrCodeFormat = formats.find( |
| (format: BarcodeFormat) => format === QR_CODE_FORMAT); |
| if (qrCodeFormat) { |
| this.qrCodeDetector_ = |
| new this.barcodeDetectorClass_({formats: [QR_CODE_FORMAT]}); |
| } |
| } |
| |
| setMediaDevices(mediaDevices: MediaDevices): void { |
| this.mediaDevices_ = mediaDevices; |
| this.updateCameraCount_(); |
| this.mediaDevices_.addEventListener( |
| 'devicechange', this.updateCameraCount_.bind(this)); |
| } |
| |
| async setFakesForTesting( |
| barcodeDetectorClass: typeof BarcodeDetector, |
| imageCaptureClass: typeof ImageCapture, |
| setIntervalFunction: (callback: Function, interval: number) => number, |
| playVideoFunction: () => void, |
| stopStreamFunction: (stream: MediaStream) => void): Promise<void> { |
| this.barcodeDetectorClass_ = barcodeDetectorClass; |
| await this.initBarcodeDetector_(); |
| this.imageCaptureClass_ = imageCaptureClass; |
| this.setIntervalFunction_ = setIntervalFunction; |
| this.playVideo_ = playVideoFunction; |
| this.stopStream_ = stopStreamFunction; |
| } |
| |
| getQrCodeDetectorTimerForTest(): number|null { |
| return this.qrCodeDetectorTimer_; |
| } |
| |
| attemptToFocusOnPageContent(): boolean { |
| // Prioritize focusing the camera button if scanning is available. |
| // TODO(b/332925540): Add interactive test for button focus. |
| if (this.isScanningAvailable_()) { |
| const useCameraBtn = this.shadowRoot!.querySelector<CrButtonElement>( |
| '#startScanningButton'); |
| |
| if (useCameraBtn) { |
| useCameraBtn.focus(); |
| return true; |
| } |
| } |
| |
| // Fallback: Focus on the activation code input |
| const activationCodeInput = |
| this.shadowRoot!.querySelector<CrInputElement>('#activationCode'); |
| if (activationCodeInput) { |
| activationCodeInput.focus(); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| private computeActivationCodeClass_(): string { |
| return this.isScanningAvailable_() ? 'relative' : 'center'; |
| } |
| |
| private updateCameraCount_(): void { |
| if (!this.mediaDevices_ || !this.mediaDevices_.enumerateDevices) { |
| this.cameraCount_ = 0; |
| return; |
| } |
| |
| this.mediaDevices_.enumerateDevices() |
| .then(devices => { |
| this.cameraCount_ = |
| devices.filter(device => device.kind === 'videoinput').length; |
| }) |
| .catch(() => { |
| this.cameraCount_ = 0; |
| }); |
| } |
| |
| private onHasCameraCountChanged_(): void { |
| // If the user was using an environment-facing camera and it was removed, |
| // restart scanning with the user-facing camera. |
| if ((this.state_ === PageState.SCANNING_ENVIRONMENT_FACING) && |
| this.cameraCount_ === 1) { |
| this.state_ = PageState.SWITCHING_CAM_ENVIRONMENT_TO_USER; |
| this.startScanning_(); |
| } |
| } |
| |
| private startScanning_(): void { |
| const oldStream = this.stream_; |
| if (this.qrCodeDetectorTimer_) { |
| this.clearQrCodeDetectorTimer_(); |
| } |
| |
| const useUserFacingCamera = |
| this.state_ !== PageState.SWITCHING_CAM_USER_TO_ENVIRONMENT; |
| this.mediaDevices_! |
| .getUserMedia({ |
| video: { |
| height: 130, |
| width: 482, |
| facingMode: useUserFacingCamera ? 'user' : 'environment', |
| }, |
| audio: false, |
| }) |
| .then(stream => { |
| this.stream_ = stream; |
| if (this.stream_) { |
| const videoElement = |
| this.shadowRoot!.querySelector<HTMLVideoElement>('#video'); |
| if (videoElement) { |
| videoElement.srcObject = stream; |
| this.playVideo_(); |
| } |
| } |
| this.stopStream_(oldStream); |
| |
| this.activationCode = ''; |
| this.state_ = useUserFacingCamera ? |
| PageState.SCANNING_USER_FACING : |
| PageState.SCANNING_ENVIRONMENT_FACING; |
| |
| if (this.stream_) { |
| this.detectQrCode_(); |
| } |
| }) |
| .catch(() => { |
| this.state_ = PageState.SCANNING_FAILURE; |
| }); |
| } |
| |
| /** |
| * Continuously checks stream if it contains a QR code. If a QR code is |
| * detected, activationCode is set to the QR code's value and the detection |
| * stops. |
| */ |
| private async detectQrCode_(): Promise<void> { |
| try { |
| this.qrCodeDetectorTimer_ = this.setIntervalFunction_( |
| (async () => { |
| assert(!!this.stream_); |
| const capturer = |
| new this.imageCaptureClass_(this.stream_.getVideoTracks()[0]); |
| const frame = await capturer.grabFrame(); |
| const activationCode = await this.detectActivationCode_(frame); |
| if (activationCode) { |
| if (this.qrCodeDetectorTimer_) { |
| this.clearQrCodeDetectorTimer_(); |
| } |
| this.activationCode = activationCode; |
| this.stopStream_(this.stream_); |
| |
| if (this.validateActivationCode_(activationCode)) { |
| this.state_ = PageState.SCANNING_SUCCESS; |
| } else { |
| // If the scanned activation code is invalid or incomplete, show |
| // error. |
| this.state_ = PageState.SCANNING_INSTALL_FAILURE; |
| } |
| } |
| }), |
| QR_CODE_DETECTION_INTERVAL_MS); |
| } catch (error) { |
| this.state_ = PageState.SCANNING_FAILURE; |
| } |
| } |
| |
| /** |
| * TODO(crbug.com/40134918): Remove suppression when shape_detection extern |
| * definitions become available. |
| */ |
| private async detectActivationCode_(frame: ImageBitmap): |
| Promise<string|null> { |
| if (!this.qrCodeDetector_) { |
| return null; |
| } |
| |
| const qrCodes = await this.qrCodeDetector_.detect(frame); |
| if (qrCodes.length > 0) { |
| return qrCodes[0].rawValue; |
| } |
| return null; |
| } |
| |
| private onActivationCodeChanged_(): void { |
| const event = new CustomEvent('activation-code-updated', { |
| bubbles: true, composed: true, detail: { |
| activationCode: this.validateActivationCode_(this.activationCode) ? |
| this.activationCode : |
| null, |
| }, |
| }); |
| |
| this.dispatchEvent(event); |
| } |
| |
| private clearQrCodeDetectorTimer_(): void { |
| assert(!!this.qrCodeDetectorTimer_); |
| clearTimeout(this.qrCodeDetectorTimer_); |
| this.qrCodeDetectorTimer_ = null; |
| } |
| |
| /** |
| * Checks if |activationCode| matches or partially matches the correct format. |
| * Sets |isActivationCodeInvalidFormat_| to true if the format is incorrect. |
| * Returns true if |activationCode| is valid and ready to be submitted for |
| * installation. |
| */ |
| private validateActivationCode_(activationCode: string): boolean { |
| if (activationCode.length <= ACTIVATION_CODE_PREFIX.length) { |
| // If the currently entered activation code is shorter than |
| // |ACTIVATION_CODE_PREFIX|, check if the code matches the format thus |
| // far. |
| this.isActivationCodeInvalidFormat_ = activationCode !== |
| ACTIVATION_CODE_PREFIX.substring(0, activationCode.length); |
| |
| // Because the entered activation code is shorter than |
| // |ACTIVATION_CODE_PREFIX| it cannot be submitted yet. |
| return false; |
| } else { |
| // |activationCode| is longer than |ACTIVATION_CODE_PREFIX|. Check if it |
| // begins with the prefix. |
| this.isActivationCodeInvalidFormat_ = |
| activationCode.substring(0, ACTIVATION_CODE_PREFIX.length) !== |
| ACTIVATION_CODE_PREFIX; |
| } |
| |
| if (this.isActivationCodeInvalidFormat_) { |
| // If the activation code does not match the format, it cannot be |
| // submitted. |
| return false; |
| } |
| return true; |
| } |
| |
| private onSwitchCameraButtonPressed_(): void { |
| if (this.state_ === PageState.SCANNING_USER_FACING) { |
| this.state_ = PageState.SWITCHING_CAM_USER_TO_ENVIRONMENT; |
| } else if (this.state_ === PageState.SCANNING_ENVIRONMENT_FACING) { |
| this.state_ = PageState.SWITCHING_CAM_ENVIRONMENT_TO_USER; |
| } |
| this.startScanning_(); |
| } |
| |
| private onShowErrorChanged_(): void { |
| if (this.showError) { |
| if (this.state_ === PageState.MANUAL_ENTRY) { |
| this.state_ = PageState.MANUAL_ENTRY_INSTALL_FAILURE; |
| afterNextRender(this, () => { |
| focusWithoutInk(this.$.activationCode); |
| }); |
| } else if (this.state_ === PageState.SCANNING_SUCCESS) { |
| this.state_ = PageState.SCANNING_INSTALL_FAILURE; |
| } |
| } |
| } |
| |
| private onStateChanged_(): void { |
| this.qrCodeCameraA11yString_ = ''; |
| if (this.state_ !== PageState.MANUAL_ENTRY_INSTALL_FAILURE && |
| this.state_ !== PageState.SCANNING_INSTALL_FAILURE) { |
| this.showError = false; |
| } |
| if (this.state_ === PageState.MANUAL_ENTRY) { |
| this.isFromQrCode = false; |
| |
| // Clear |qrCodeDetectorTimer_| before closing video stream, prevents |
| // image capturer from going into an inactive state and throwing errors |
| // when |grabFrame()| is called. |
| if (this.qrCodeDetectorTimer_) { |
| this.clearQrCodeDetectorTimer_(); |
| } |
| |
| // Wait for the video element to be hidden by isUiElementHidden() before |
| // stopping the stream or the user will see a flash. |
| afterNextRender(this, () => { |
| this.stopStream_(this.stream_); |
| }); |
| } |
| |
| if (this.state_ === PageState.SCANNING_USER_FACING || |
| this.state_ === PageState.SCANNING_ENVIRONMENT_FACING) { |
| this.qrCodeCameraA11yString_ = this.i18n('qrCodeA11YCameraOn'); |
| this.expanded_ = true; |
| return; |
| } |
| |
| // Focus on the next button after scanning is successful. |
| if (this.state_ === PageState.SCANNING_SUCCESS) { |
| this.isFromQrCode = true; |
| this.qrCodeCameraA11yString_ = this.i18n('qrCodeA11YCameraScanSuccess'); |
| this.dispatchEvent(new CustomEvent('focus-default-button', { |
| bubbles: true, |
| composed: true, |
| })); |
| } |
| |
| this.expanded_ = false; |
| } |
| |
| private onKeyDown_(e: KeyboardEvent): void { |
| if (e.key === 'Enter') { |
| this.dispatchEvent(new CustomEvent('forward-navigation-requested', { |
| bubbles: true, |
| composed: true, |
| })); |
| } |
| |
| // Prevents barcode detector video from closing if user tabs through |
| // window. We should only close barcode detector window if user |
| // types in activation code input. |
| if (e.key === 'Tab') { |
| return; |
| } |
| |
| this.state_ = PageState.MANUAL_ENTRY; |
| e.stopPropagation(); |
| } |
| |
| private isUiElementHidden_(uiElement: UiElement, state: PageState): boolean { |
| switch (uiElement) { |
| case UiElement.START_SCANNING: |
| return state !== PageState.MANUAL_ENTRY && |
| state !== PageState.MANUAL_ENTRY_INSTALL_FAILURE; |
| case UiElement.VIDEO: |
| return state !== PageState.SCANNING_USER_FACING && |
| state !== PageState.SCANNING_ENVIRONMENT_FACING; |
| case UiElement.SWITCH_CAMERA: |
| const isScanning = state === PageState.SCANNING_USER_FACING || |
| state === PageState.SCANNING_ENVIRONMENT_FACING; |
| return !(isScanning && this.cameraCount_ > 1); |
| case UiElement.SCAN_FINISH: |
| return state !== PageState.SCANNING_SUCCESS && |
| state !== PageState.SCANNING_FAILURE && |
| state !== PageState.SCANNING_INSTALL_FAILURE; |
| case UiElement.SCAN_SUCCESS: |
| return state !== PageState.SCANNING_SUCCESS && |
| state !== PageState.SCANNING_INSTALL_FAILURE; |
| case UiElement.SCAN_FAILURE: |
| return state !== PageState.SCANNING_FAILURE; |
| case UiElement.CODE_DETECTED: |
| return state !== PageState.SCANNING_SUCCESS; |
| case UiElement.SCAN_INSTALL_FAILURE: |
| return state !== PageState.SCANNING_INSTALL_FAILURE; |
| } |
| } |
| |
| private isUiElementDisabled_(uiElement: UiElement, state: PageState): |
| boolean { |
| switch (uiElement) { |
| case UiElement.SWITCH_CAMERA: |
| return state === PageState.SWITCHING_CAM_USER_TO_ENVIRONMENT || |
| state === PageState.SWITCHING_CAM_ENVIRONMENT_TO_USER; |
| default: |
| return false; |
| } |
| } |
| |
| private getDescription_(): string { |
| if (!this.isScanningAvailable_()) { |
| if (this.showNoProfilesFound) { |
| return this.i18n('enterActivationCodeNoProfilesFound'); |
| } |
| return this.i18n('enterActivationCode'); |
| } |
| if (this.showNoProfilesFound) { |
| return this.i18n('scanQRCodeNoProfilesFound'); |
| } |
| return this.i18n('scanQRCode'); |
| } |
| |
| private shouldActivationCodeInputBeInvalid_(state: PageState): boolean { |
| if (this.isActivationCodeInvalidFormat_) { |
| return true; |
| } |
| return state === PageState.MANUAL_ENTRY_INSTALL_FAILURE; |
| } |
| |
| private getInputSubtitle_(): string { |
| // Because this string contains '<' and '>' characters, we cannot use i18n |
| // methods. |
| return loadTimeData.getString('scanQrCodeInputSubtitle'); |
| } |
| |
| private getInputErrorMessage_(): string { |
| // Because this string contains '<' and '>' characters, we cannot use i18n |
| // methods. |
| return loadTimeData.getString('scanQrCodeInputError'); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| [ActivationCodePageElement.is]: ActivationCodePageElement; |
| } |
| } |
| |
| customElements.define(ActivationCodePageElement.is, ActivationCodePageElement); |