From 79707d28117b2d3d6fb01268a9fec078a6285553 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Mon, 5 May 2025 20:52:20 +0800 Subject: [PATCH] WIP: Unified contact QR code display + capture --- src/services/QRScanner/CapacitorQRScanner.ts | 5 + src/services/QRScanner/QRScannerFactory.ts | 4 +- src/services/QRScanner/QRScannerService.ts | 17 ++ src/services/QRScanner/WebInlineQRScanner.ts | 195 +++++++++++++ src/views/ContactQRScanShowView.vue | 277 +++++++++++++++---- 5 files changed, 447 insertions(+), 51 deletions(-) create mode 100644 src/services/QRScanner/QRScannerService.ts create mode 100644 src/services/QRScanner/WebInlineQRScanner.ts diff --git a/src/services/QRScanner/CapacitorQRScanner.ts b/src/services/QRScanner/CapacitorQRScanner.ts index 57b84455..dc5a1f0b 100644 --- a/src/services/QRScanner/CapacitorQRScanner.ts +++ b/src/services/QRScanner/CapacitorQRScanner.ts @@ -202,4 +202,9 @@ export class CapacitorQRScanner implements QRScannerService { return this.cleanupPromise; } + + onStream(callback: (stream: MediaStream | null) => void): void { + // No-op for native scanner + callback(null); + } } diff --git a/src/services/QRScanner/QRScannerFactory.ts b/src/services/QRScanner/QRScannerFactory.ts index 2edc7602..1b7183a7 100644 --- a/src/services/QRScanner/QRScannerFactory.ts +++ b/src/services/QRScanner/QRScannerFactory.ts @@ -1,7 +1,7 @@ import { Capacitor } from "@capacitor/core"; import { QRScannerService } from "./types"; import { CapacitorQRScanner } from "./CapacitorQRScanner"; -import { WebDialogQRScanner } from "./WebDialogQRScanner"; +import { WebInlineQRScanner } from "./WebInlineQRScanner"; import { logger } from "@/utils/logger"; /** @@ -69,7 +69,7 @@ export class QRScannerFactory { : !isNative ) { logger.log("Using web QR scanner"); - this.instance = new WebDialogQRScanner(); + this.instance = new WebInlineQRScanner(); } else { throw new Error( "No QR scanner implementation available for this platform", diff --git a/src/services/QRScanner/QRScannerService.ts b/src/services/QRScanner/QRScannerService.ts new file mode 100644 index 00000000..a6376053 --- /dev/null +++ b/src/services/QRScanner/QRScannerService.ts @@ -0,0 +1,17 @@ +import { EventEmitter } from "events"; + +export interface QRScannerListener { + onScan: (result: string) => void; + onError: (error: Error) => void; +} + +export interface QRScannerService { + checkPermissions(): Promise; + requestPermissions(): Promise; + isSupported(): Promise; + startScan(): Promise; + stopScan(): Promise; + addListener(listener: QRScannerListener): void; + cleanup(): Promise; + onStream(callback: (stream: MediaStream | null) => void): void; +} \ No newline at end of file diff --git a/src/services/QRScanner/WebInlineQRScanner.ts b/src/services/QRScanner/WebInlineQRScanner.ts new file mode 100644 index 00000000..d2953114 --- /dev/null +++ b/src/services/QRScanner/WebInlineQRScanner.ts @@ -0,0 +1,195 @@ +import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; +import { logger } from "@/utils/logger"; +import { EventEmitter } from "events"; + +export class WebInlineQRScanner implements QRScannerService { + private scanListener: ScanListener | null = null; + private isScanning = false; + private stream: MediaStream | null = null; + private events = new EventEmitter(); + + constructor(private options?: QRScannerOptions) {} + + async checkPermissions(): Promise { + try { + logger.log("[QRScanner] Checking camera permissions..."); + const permissions = await navigator.permissions.query({ + name: "camera" as PermissionName, + }); + logger.log("[QRScanner] Permission state:", permissions.state); + return permissions.state === "granted"; + } catch (error) { + logger.error("[QRScanner] Error checking camera permissions:", error); + return false; + } + } + + async requestPermissions(): Promise { + try { + // First check if we have any video devices + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoDevices = devices.filter( + (device) => device.kind === "videoinput", + ); + + if (videoDevices.length === 0) { + logger.error("No video devices found"); + throw new Error("No camera found on this device"); + } + + // Try to get a stream with specific constraints + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: "environment", + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + + // Stop the test stream immediately + stream.getTracks().forEach((track) => track.stop()); + return true; + } catch (error) { + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error requesting camera permissions:", { + error: wrappedError.message, + stack: wrappedError.stack, + name: wrappedError.name, + }); + + // Provide more specific error messages + if ( + wrappedError.name === "NotFoundError" || + wrappedError.name === "DevicesNotFoundError" + ) { + throw new Error("No camera found on this device"); + } else if ( + wrappedError.name === "NotAllowedError" || + wrappedError.name === "PermissionDeniedError" + ) { + throw new Error( + "Camera access denied. Please grant camera permission and try again", + ); + } else if ( + wrappedError.name === "NotReadableError" || + wrappedError.name === "TrackStartError" + ) { + throw new Error("Camera is in use by another application"); + } else { + throw new Error(`Camera error: ${wrappedError.message}`); + } + } + } + + async isSupported(): Promise { + try { + // Check for secure context first + if (!window.isSecureContext) { + logger.warn("Camera access requires HTTPS (secure context)"); + return false; + } + + // Check for camera API support + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + logger.warn("Camera API not supported in this browser"); + return false; + } + + // Check if we have any video devices + const devices = await navigator.mediaDevices.enumerateDevices(); + const hasVideoDevices = devices.some( + (device) => device.kind === "videoinput", + ); + + if (!hasVideoDevices) { + logger.warn("No video devices found"); + return false; + } + + return true; + } catch (error) { + logger.error("Error checking camera support:", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + return false; + } + } + + async startScan(): Promise { + if (this.isScanning) { + return; + } + + try { + this.isScanning = true; + logger.log("[WebInlineQRScanner] Starting scan"); + + // Get camera stream + this.stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: "environment", + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + + // Emit stream to component + this.events.emit("stream", this.stream); + } catch (error) { + this.isScanning = false; + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + if (this.scanListener?.onError) { + this.scanListener.onError(wrappedError); + } + logger.error("Error starting scan:", wrappedError); + throw wrappedError; + } + } + + async stopScan(): Promise { + if (!this.isScanning) { + return; + } + + try { + logger.log("[WebInlineQRScanner] Stopping scan"); + + // Stop all tracks in the stream + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + + // Emit stream stopped event + this.events.emit("stream", null); + } catch (error) { + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error stopping scan:", wrappedError); + throw wrappedError; + } finally { + this.isScanning = false; + } + } + + addListener(listener: ScanListener): void { + this.scanListener = listener; + } + + // Add method to get stream events + onStream(callback: (stream: MediaStream | null) => void): void { + this.events.on("stream", callback); + } + + async cleanup(): Promise { + try { + await this.stopScan(); + this.events.removeAllListeners(); + } catch (error) { + logger.error("Error during cleanup:", error); + } + } +} \ No newline at end of file diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 6a39410d..8ef97f80 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -2,37 +2,35 @@
- -
- -
-

+

+ + -

-
+ - -

Your Contact Info

-

- Beware! - You aren't sharing your name, so quickly -
- - click here to set it for them. - -

+ +

+ Beware! + You aren't sharing your name, so quickly +
+ + click here to set it for them. + +

+
-

Scan Contact Info

-
+

Scan Contact Info

+
+ +
+
+ + + + + {{ initializationStatus }} +
+

+ + Position QR code in the frame +

+

+ Error: {{ error }} +

+

+ + Ready to scan +

+
+ + + +
+ + +
+ Camera: {{ preferredCamera === "user" ? "Front" : "Back" }} | + Status: {{ cameraStatus }} +
+ + +
- - -
@@ -121,10 +214,10 @@ import QRCodeVue3 from "qr-code-generator-vue3"; import { Component, Vue } from "vue-facing-decorator"; import { useClipboard } from "@vueuse/core"; import { Capacitor } from "@capacitor/core"; +import { QrcodeStream } from "vue-qrcode-reader"; import QuickNav from "../components/QuickNav.vue"; import UserNameDialog from "../components/UserNameDialog.vue"; -import QRScannerDialog from "../components/QRScanner/QRScannerDialog.vue"; import { NotificationIface } from "../constants/app"; import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { Contact } from "../db/tables/contacts"; @@ -139,7 +232,8 @@ import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; import { retrieveAccountMetadata } from "../libs/util"; import { Router } from "vue-router"; import { logger } from "../utils/logger"; -import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory"; +import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; +import { WebInlineQRScanner } from "@/services/QRScanner/WebInlineQRScanner"; interface QRScanResult { rawValue?: string; @@ -155,7 +249,7 @@ interface IUserNameDialog { QRCodeVue3, QuickNav, UserNameDialog, - QRScannerDialog, + QrcodeStream, }, }) export default class ContactQRScanShow extends Vue { @@ -170,9 +264,15 @@ export default class ContactQRScanShow extends Vue { qrValue = ""; isScanning = false; error: string | null = null; - showScannerDialog = false; isNativePlatform = Capacitor.isNativePlatform(); + // QR Scanner properties + isInitializing = true; + initializationStatus = "Initializing camera..."; + useQRReader = __USE_QR_READER__; + preferredCamera: "user" | "environment" = "environment"; + cameraStatus = "Initializing"; + ETHR_DID_PREFIX = ETHR_DID_PREFIX; // Add new properties to track scanning state @@ -230,6 +330,8 @@ export default class ContactQRScanShow extends Vue { try { this.error = null; this.isScanning = true; + this.isInitializing = true; + this.initializationStatus = "Initializing camera..."; this.lastScannedValue = ""; this.lastScanTime = 0; @@ -240,6 +342,7 @@ export default class ContactQRScanShow extends Vue { this.error = "Camera access requires HTTPS. Please use a secure connection."; this.isScanning = false; + this.isInitializing = false; this.$notify( { group: "alert", @@ -254,10 +357,12 @@ export default class ContactQRScanShow extends Vue { // Check permissions first if (!(await scanner.checkPermissions())) { + this.initializationStatus = "Requesting camera permission..."; const granted = await scanner.requestPermissions(); if (!granted) { this.error = "Camera permission denied"; this.isScanning = false; + this.isInitializing = false; // Show notification for better visibility this.$notify( { @@ -272,12 +377,6 @@ export default class ContactQRScanShow extends Vue { } } - // Show the scanner dialog for web - if (!this.isNativePlatform) { - this.showScannerDialog = true; - return; - } - // For native platforms, use the scanner service scanner.addListener({ onScan: this.onScanDetect, @@ -289,6 +388,7 @@ export default class ContactQRScanShow extends Vue { } catch (error) { this.error = error instanceof Error ? error.message : String(error); this.isScanning = false; + this.isInitializing = false; logger.error("Error starting scan:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, @@ -601,11 +701,6 @@ export default class ContactQRScanShow extends Vue { }); } - closeScannerDialog() { - this.showScannerDialog = false; - this.isScanning = false; - } - // Lifecycle hooks mounted() { this.isMounted = true; @@ -734,6 +829,90 @@ export default class ContactQRScanShow extends Vue { ); } } + + async onInit(promise: Promise): Promise { + logger.log("[QRScanner] onInit called"); + if (this.isNativePlatform) { + logger.log("Skipping QR scanner initialization on native platform"); + return; + } + + try { + await promise; + this.isInitializing = false; + this.cameraStatus = "Ready"; + } catch (error) { + const wrappedError = error instanceof Error ? error : new Error(String(error)); + this.error = wrappedError.message; + this.cameraStatus = "Error"; + this.isInitializing = false; + logger.error("Error during QR scanner initialization:", { + error: wrappedError.message, + stack: wrappedError.stack, + }); + } + } + + onCameraOn(): void { + this.cameraStatus = "Active"; + this.isInitializing = false; + } + + onCameraOff(): void { + this.cameraStatus = "Off"; + } + + onDetect(result: any): void { + this.isScanning = true; + this.cameraStatus = "Detecting"; + try { + let rawValue: string | undefined; + if (Array.isArray(result) && result.length > 0 && "rawValue" in result[0]) { + rawValue = result[0].rawValue; + } else if (result && typeof result === "object" && "rawValue" in result) { + rawValue = result.rawValue; + } + if (rawValue) { + this.isInitializing = false; + this.initializationStatus = "QR code captured!"; + this.onScanDetect(rawValue); + } + } catch (error) { + this.handleError(error); + } finally { + this.isScanning = false; + this.cameraStatus = "Active"; + } + } + + onDecode(result: string): void { + try { + this.isInitializing = false; + this.initializationStatus = "QR code captured!"; + this.onScanDetect(result); + } catch (error) { + this.handleError(error); + } + } + + toggleCamera(): void { + this.preferredCamera = this.preferredCamera === "user" ? "environment" : "user"; + } + + private handleError(error: unknown): void { + const wrappedError = error instanceof Error ? error : new Error(String(error)); + this.error = wrappedError.message; + this.cameraStatus = "Error"; + } + + onError(error: Error): void { + this.error = error.message; + this.cameraStatus = "Error"; + logger.error("QR code scan error:", { + error: error.message, + stack: error.stack, + }); + } }