import { Component, NgZone, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { QRScanner, QRScannerStatus } from '@ionic-native/qr-scanner/ngx'; import { AlertController } from '@ionic/angular'; import { TranslateService } from '@ngx-translate/core'; import { isObject } from 'lodash-es'; import QrScanner from 'qr-scanner'; import { Subscription } from 'rxjs'; import { TitleBarComponent } from 'src/app/components/titlebar/titlebar.component'; import { TitleBarIcon, TitleBarIconSlot, TitleBarMenuItem } from 'src/app/components/titlebar/titlebar.types'; import { Logger } from 'src/app/logger'; import { App } from 'src/app/model/app.enum'; import { GlobalIntentService } from 'src/app/services/global.intent.service'; import { GlobalNavService } from 'src/app/services/global.nav.service'; import { GlobalThemeService } from 'src/app/services/global.theme.service'; import { GlobalWalletConnectService, WalletConnectSessionRequestSource } from 'src/app/services/global.walletconnect.service'; import { IntentService } from '../../services/intent.service'; // The worker JS file from qr-scanner must be copied manually from // the qr-scanner node_modules sources and copied to our assets/folder QrScanner.WORKER_PATH = "./assets/scanner/qr-scanner-worker.min.js" export type ScanPageRouteParams = { fromIntent: boolean } @Component({ selector: 'app-scan', templateUrl: './scan.page.html', styleUrls: ['./scan.page.scss'], }) export class ScanPage { @ViewChild(TitleBarComponent, { static: true }) titleBar: TitleBarComponent; torchLightOn: boolean; isCameraShown = false; contentWasScanned = false; scannedText = ""; scanSub: Subscription = null; fromIntentRequest = false; loader: any = null; alert: any = null; alreadySentIntentResponce = false; private titleBarIconClickedListener: (icon: TitleBarIcon | TitleBarMenuItem) => void; constructor( public route: ActivatedRoute, public router: Router, private qrScanner: QRScanner, private ngZone: NgZone, private intentService: IntentService, private zone: NgZone, private alertController: AlertController, public theme: GlobalThemeService, private globalIntentService: GlobalIntentService, private globalWalletConnectService: GlobalWalletConnectService, private globalNav: GlobalNavService, private translate: TranslateService, ) { const navigation = this.router.getCurrentNavigation(); if (isObject(navigation.extras.state)) { this.fromIntentRequest = navigation.extras.state.fromIntent; } } ionViewWillEnter() { this.titleBar.setTitle(this.translate.instant('launcher.app-scanner')); this.showGalleryTitlebarKey(true); this.titleBar.addOnItemClickedListener(this.titleBarIconClickedListener = (clickedItem) => { if (clickedItem.key == "gallery") { void this.scanFromLibrary(); } }); } ionViewDidEnter() { Logger.log("Scanner", "Starting scanning process"); this.startScanningProcess(); } /** * Leaving the page, do some cleanup. */ ionViewWillLeave() { // Must send intent response for intent when click back key. if (this.fromIntentRequest && !this.alreadySentIntentResponce) { void this.returnScannedContentToIntentRequester('', false); } this.titleBar.removeOnItemClickedListener(this.titleBarIconClickedListener); this.zone.run(() => { Logger.log("Scanner", "Scan view is leaving") this.stopScanning(); void this.hideCamera().then(() => { document.body.classList.remove("transparentBody"); }) }); } /** * Toggle flash light on or off */ toggleLight() { this.torchLightOn = !this.torchLightOn; if (!this.torchLightOn) return this.qrScanner.disableLight(); else return this.qrScanner.enableLight(); } async showCamera() { // Make sure to make ion-app and ion-content transparent to see the camera preview document.body.classList.add("transparentBody"); await this.qrScanner.show(); this.isCameraShown = true; // Will display controls } async hideCamera() { window.document.querySelector('ion-app').classList.remove('transparentBody') await this.qrScanner.hide(); await this.qrScanner.destroy(); this.zone.run(() => { this.isCameraShown = false; }); } startScanningProcess() { this.qrScanner.prepare().then(async (status: QRScannerStatus) => { Logger.log("Scanner", "Scanner prepared") if (status.authorized) { // Camera permission was granted. Start scanning Logger.log("Scanner", "Scanner authorized") // Show camera preview Logger.log("Scanner", "Showing camera preview") await this.showCamera() // Start scanning and listening to scan results // eslint-disable-next-line @typescript-eslint/no-misused-promises this.scanSub = this.qrScanner.scan().subscribe(async (text: string) => { // Can not show the scan data. Private data, confidential. eg. mnemonic. Logger.log("Scanner", "Scanned data length: ", text.length); this.scannedText = text; this.ngZone.run(() => { this.contentWasScanned = true }); await this.hideCamera() this.stopScanning() // Either emit a new intent if the scanner app was opened manually, or // send a intent resposne if this app was opened by a "scanqrcode" intent request. if (!this.fromIntentRequest) await this.runScannedContent(this.scannedText) else await this.returnScannedContentToIntentRequester(this.scannedText); }); // Wait for user to scan something, then the observable callback will be called } else if (status.denied) { // Camera permission was permanently denied Logger.log("Scanner", "Access to QRScanner plugin was permanently denied") } else { // Permission was denied, but not permanently. You can ask for permission again at a later time. Logger.log("Scanner", "Access to QRScanner plugin is currently denied") } }).catch((e: any) => Logger.error("Scanner", 'Unexpected error: ', e, e)); } stopScanning() { if (this.scanSub) { this.scanSub.unsubscribe(); this.scanSub = null; } } showGalleryTitlebarKey(show: boolean) { if (show) { this.titleBar.setIcon(TitleBarIconSlot.OUTER_RIGHT, { key: "gallery", iconPath: !this.theme.darkMode ? "assets/scanner/imgs/gallery.svg" : "assets/scanner/imgs/darkmode/gallery.svg" }); } else { this.titleBar.setIcon(TitleBarIconSlot.OUTER_RIGHT, null); } } /** * Initiates a QR code scanning from a picture chosen from the library by the user. */ async scanFromLibrary() { if (this.alert) { await this.alertController.dismiss(); this.alert = null; } Logger.log("Scanner", "Stopping camera, getting ready to pick a picture from the gallery."); await this.hideCamera(); this.stopScanning(); this.showGalleryTitlebarKey(false); setTimeout(() => { Logger.log("Scanner", "Opening gallery to pick a picture"); // Ask user to pick a picture from the library navigator.camera.getPicture((data) => { Logger.log("Scanner", "Got gallery data"); if (data) { this.zone.run(() => { try { const image = new Image(); image.onload = async () => { Logger.log("Scanner", "Loaded image size:", image.width, image.height); let code: string; try { // why? // We create worker manually. // if use 'QrScanner.scanImage(image)', it will create BarcodeDetector engine in some devices, and it can't get the qr code. let worker: Worker = new Worker(QrScanner.WORKER_PATH) code = await QrScanner.scanImage(image, null, worker); } catch (err) { Logger.error("Scanner", err); code = null; } Logger.log("Scanner", "Read qr code:", code); if (code != null) { this.showGalleryTitlebarKey(true); // A QR code could be found in the picture this.scannedText = code as string; if (!this.fromIntentRequest) await this.runScannedContent(this.scannedText) else await this.returnScannedContentToIntentRequester(this.scannedText); } else { void this.alertNoScannedContent('common.sorry', 'scanner.no-qr-err'); } } image.src = "data:image/png;base64," + data; // base64 string // Free the memory navigator.camera.cleanup(() => { }, (err) => { }); } catch (e) { void this.alertNoScannedContent('common.sorry', 'scanner.scan-err'); Logger.warn("Scanner", "Error while loading the picture as PNG:", e); } }); } } , (err) => { // 'No Image Selected': User canceled. if (err === 'No Image Selected') { this.showGalleryTitlebarKey(true); this.zone.run(() => { this.startScanningProcess(); }); } else { Logger.error("Scanner", err); void this.alertNoScannedContent('sorry', 'scanner.gallery-err'); } }, { targetWidth: 1200, // Reduce picture size to avoid memory problems - keep it large enough for QR code readabilitiy targetHeight: 1200, destinationType: 0, // Return as base64 data string sourceType: 0, // Pick from photo library encodingType: 1 // Return as PNG base64 data }); }, 100); } async returnScannedContentToIntentRequester(scannedContent: string, navigateBack = true) { this.alreadySentIntentResponce = true; await this.intentService.sendScanQRCodeIntentResponse(scannedContent, navigateBack); } /** * Executes the scanned content. If the content is recognized as a URL, we send a URL intent. * Otherwise, we send a "handlescannedcontent" intent so that user can pick an app to use this content * (ex: scanned content is a ELA address, so user may choose to open the wallet app to send ELA to this address) */ async runScannedContent(scannedContent: string) { // pop scanner from navigation history, so the nav will not navigate to scanner. await this.globalNav.exitCurrentContext(false); // Special case - DID FORMAT CHECK - DIDs are considered as URLs by the URL class if (this.contentIsElastosDID(scannedContent)) { await this.sendIntentAsRaw(scannedContent) } else if (this.globalWalletConnectService.canHandleUri(scannedContent)) { await this.globalWalletConnectService.handleWCURIRequest(scannedContent, WalletConnectSessionRequestSource.SCANNER); } else { try { new URL(scannedContent); await this.sendIntentAsUrl(scannedContent); } catch (_) { // Content can't be parsed as a URL: fallback solution is to use it as raw content await this.sendIntentAsRaw(scannedContent); } } } async sendIntentAsUrl(scannedContent: string) { // Special backward compatibility case: convert elastos:// into https://did.elastos.net/ for CR sign in if (scannedContent.indexOf("elastos://") === 0) scannedContent = scannedContent.replace("elastos://", "https://did.elastos.net/"); try { Logger.log("Scanner", "Sending scanned content as a URL intent:", scannedContent); await this.globalIntentService.sendUrlIntent(scannedContent); // URL intent sent Logger.log("Scanner", "sendUrlIntent successfully") await this.exitApp() } catch (err) { Logger.error("Scanner", "sendUrlIntent failed", err) // We call exitCurrentContext before, // so we need add scanner to navigation history again if do not exit scanner. void this.globalNav.navigateRoot(App.SCANNER) this.ngZone.run(() => { void this.showNooneToHandleIntent() }) } } async sendIntentAsRaw(scannedContent: string) { let scanIntentAction = ""; // Handle specific content types to redirect to a more appropriate action. // DID FORMAT CHECK if (this.contentIsElastosDID(scannedContent)) { // The scanned content seems to be a Elastos DID -> send this as a scanned "DID". scanIntentAction = "handlescannedcontent_did" } else { scanIntentAction = "handlescannedcontent" } try { Logger.log("Scanner", "Sending scanned content as raw content to an " + scanIntentAction + " intent action"); await this.globalIntentService.sendIntent(scanIntentAction, { data: scannedContent }); // Raw intent sent Logger.log("Scanner", "Intent sent successfully as action '" + scanIntentAction + "'") await this.exitApp(); } catch (err) { Logger.warn("Scanner", "Intent sending failed", err) // We call exitCurrentContext before, // so we need add scanner to navigation history again if do not exit scanner. this.ngZone.run(() => { void this.globalNav.navigateRoot(App.SCANNER) void this.showNooneToHandleIntent() }) } } contentIsElastosDID(scannedContent) { return (scannedContent.indexOf("did:elastos:") == 0); } async showNooneToHandleIntent() { this.alert = await this.alertController.create({ mode: 'ios', message: this.translate.instant('scanner.no-app-err'), backdropDismiss: false, buttons: [ { text: this.translate.instant('common.ok'), handler: () => { this.startScanningProcess(); } } ] }); this.alert.onWillDismiss().then(() => { this.alert = null; }); this.alert.present() } async alertNoScannedContent(title: string, msg: string, btnText = 'ok') { this.showGalleryTitlebarKey(true); this.alert = await this.alertController.create({ mode: 'ios', header: this.translate.instant(title), message: this.translate.instant(msg), backdropDismiss: false, buttons: [ { text: this.translate.instant(btnText), handler: () => { this.startScanningProcess(); } } ] }); this.alert.onWillDismiss().then(() => { this.alert = null; }); this.alert.present() } async exitApp() { Logger.log("Scanner", "Exiting app") this.stopScanning(); await this.hideCamera(); } }