diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 37d47ad9..3807029e 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -2,6 +2,15 @@
+ +
+
+
+

{{ processingStatus }}

+

{{ processingDetails }}

+
+
+
@@ -202,6 +211,20 @@ interface WorkerErrorMessage { type WorkerMessage = WorkerSuccessMessage | WorkerErrorMessage; +interface WorkerResult { + success: boolean; + code?: { + data: string; + location: { + topLeft: { x: number; y: number }; + topRight: { x: number; y: number }; + bottomLeft: { x: number; y: number }; + bottomRight: { x: number; y: number }; + }; + }; + error?: string; +} + @Component({ components: { QrcodeStream: __USE_QR_READER__ ? QrcodeStream : null, @@ -238,42 +261,75 @@ export default class ContactQRScanShow extends Vue { private isCapturingPhoto = false; private appStateListener?: { remove: () => Promise }; + private isProcessing = false; + private processingStatus = ''; + private processingDetails = ''; + + private worker: Worker | null = null; + private workerInitialized = false; + private initializationAttempts = 0; + private readonly MAX_INITIALIZATION_ATTEMPTS = 3; + async created() { logger.log('ContactQRScanShow component created'); try { // Remove any existing listeners first - await App.removeAllListeners(); + await this.cleanupAppListeners(); // Add app state listeners - const stateListener = await App.addListener('appStateChange', (state: AppStateChangeEvent) => { + await this.setupAppLifecycleListeners(); + + // Load initial data + await this.loadInitialData(); + + logger.log('ContactQRScanShow initialization complete'); + } catch (error) { + logger.error('Failed to initialize ContactQRScanShow:', error); + this.showError('Failed to initialize. Please try again.'); + } + } + + private async cleanupAppListeners(): Promise { + try { + if (this.appStateListener) { + await this.appStateListener.remove(); + this.appStateListener = undefined; + } + await App.removeAllListeners(); + logger.log('App listeners cleaned up successfully'); + } catch (error) { + logger.error('Error cleaning up app listeners:', error); + throw error; + } + } + + private async setupAppLifecycleListeners(): Promise { + try { + // Add app state change listener + this.appStateListener = await App.addListener('appStateChange', (state: AppStateChangeEvent) => { logger.log('App state changed:', state); - if (!state.isActive && this.cameraActive) { + if (!state.isActive) { this.cleanupCamera(); } }); - this.appStateListener = stateListener; + // Add pause listener await App.addListener('pause', () => { logger.log('App paused'); - if (this.cameraActive) { - this.cleanupCamera(); - } + this.cleanupCamera(); }); + // Add resume listener await App.addListener('resume', () => { logger.log('App resumed'); - if (this.cameraActive) { - this.initializeCamera(); - } + // Don't automatically reinitialize camera on resume + // Let user explicitly request camera access again }); - // Load initial data - await this.loadInitialData(); - - logger.log('ContactQRScanShow initialization complete'); + logger.log('App lifecycle listeners setup complete'); } catch (error) { - logger.error('Failed to initialize ContactQRScanShow:', error); - this.showError('Failed to initialize. Please try again.'); + logger.error('Error setting up app lifecycle listeners:', error); + throw error; } } @@ -292,22 +348,34 @@ export default class ContactQRScanShow extends Vue { } } - private cleanupCamera() { + private async cleanupCamera() { try { - this.cameraActive = false; - this.isCapturingPhoto = false; - this.addCameraState('cleanup'); - logger.log('Camera cleaned up successfully'); + if (this.cameraActive) { + this.cameraActive = false; + this.isCapturingPhoto = false; + this.addCameraState('cleanup'); + logger.log('Camera cleaned up successfully'); + } } catch (error) { logger.error('Error during camera cleanup:', error); } } private addCameraState(state: CameraState | QRProcessingState, details?: Record) { + // Prevent duplicate state transitions + if (this.lastCameraState === state) { + return; + } + const entry: CameraStateHistoryEntry = { state, timestamp: Date.now(), - details + details: { + ...details, + cameraActive: this.cameraActive, + isCapturingPhoto: this.isCapturingPhoto, + isProcessing: this.isProcessing + } }; this.cameraStateHistory.push(entry); @@ -316,17 +384,11 @@ export default class ContactQRScanShow extends Vue { this.cameraStateHistory.shift(); } - // Enhanced logging with better details - logger.log('Camera state transition:', { - state, - details: { - ...details, - cameraActive: this.cameraActive, - isCapturingPhoto: this.isCapturingPhoto, - historyLength: this.cameraStateHistory.length - } + this.logWithDetails('Camera state transition', { + state: entry.state, + timestamp: entry.timestamp, + details: entry.details }); - this.lastCameraState = state; } @@ -363,6 +425,11 @@ export default class ContactQRScanShow extends Vue { ]).catch(error => { logger.error('Error during component cleanup:', error); }); + + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } } private async handleQRCodeResult(data: string): Promise { @@ -456,114 +523,376 @@ export default class ContactQRScanShow extends Vue { } } - async processImageForQRCode(imageDataUrl: string): Promise { + private async initializeWorker(): Promise { try { - logger.log('Starting QR code processing'); + if (this.initializationAttempts >= this.MAX_INITIALIZATION_ATTEMPTS) { + throw new Error('Maximum worker initialization attempts reached'); + } + + this.initializationAttempts++; + this.setProcessingStatus('Initializing', `Setting up QR scanner (attempt ${this.initializationAttempts})...`); + logger.log('Starting worker initialization', { attempt: this.initializationAttempts }); - // Create worker for image processing - const worker = new Worker(URL.createObjectURL(new Blob([` - self.onmessage = async function(e) { + // Import and wait for jsQR to be fully loaded + logger.log('Loading jsQR module...'); + const jsQRModule = await import('jsqr'); + if (!jsQRModule?.default) { + throw new Error('Failed to load jsQR module'); + } + logger.log('jsQR module loaded successfully'); + + // Create worker with inline code and bundled jsQR + const workerCode = ` + let jsQRInitialized = false; + let initializationError = null; + + try { + // Get the raw jsQR function code + const jsQRCode = ${jsQRModule.default.toString()}; + + // Create a proper module-like environment + const context = { + module: { exports: {} }, + exports: {}, + self: self, + window: self, // Some modules expect window to be defined + global: self // Some modules expect global to be defined + }; + + // Create a function that will run in the proper context + const initFunction = new Function( + 'module', 'exports', 'self', 'window', 'global', + jsQRCode + ); + + // Execute the function in our context + initFunction.call( + context, + context.module, + context.exports, + context.self, + context.window, + context.global + ); + + // Get the jsQR function - it might be exported directly or as default + self.jsQR = context.module.exports.default || context.module.exports; + + // Verify the function works + if (typeof self.jsQR === 'function') { + // Test with a small dummy image + const testData = new Uint8ClampedArray(4 * 4 * 4); + self.jsQR(testData, 4, 4, { inversionAttempts: "dontInvert" }); + + jsQRInitialized = true; + self.postMessage({ initialized: true }); + } else { + throw new Error('jsQR is not a function after initialization'); + } + } catch (error) { + initializationError = error; + console.error('Worker initialization error:', error); + self.postMessage({ + error: 'Failed to initialize jsQR: ' + error.message, + details: { + errorType: error.name, + errorStack: error.stack, + jsQRType: typeof self.jsQR + } + }); + } + + self.onmessage = function(e) { + if (!jsQRInitialized) { + self.postMessage({ + success: false, + error: 'QR scanner not initialized', + details: { + initializationError: initializationError ? initializationError.message : 'Unknown error', + jsQRType: typeof self.jsQR + } + }); + return; + } + const { imageData, width, height } = e.data; try { - // Import jsQR in the worker - importScripts('${window.location.origin}/assets/jsqr.js'); - const code = self.jsQR(imageData, width, height, { - inversionAttempts: "dontInvert" + const uint8Array = new Uint8ClampedArray(imageData); + + // Try normal orientation + let code = self.jsQR(uint8Array, width, height, { + inversionAttempts: "attemptBoth" }); - self.postMessage({ success: true, code }); + + if (!code) { + // Try rotated 90 degrees + const rotated = rotateImageData(new ImageData(uint8Array, width, height), width, height); + if (rotated) { + code = self.jsQR(new Uint8ClampedArray(rotated.data), rotated.width, rotated.height, { + inversionAttempts: "attemptBoth" + }); + } + } + + self.postMessage(code ? { success: true, code } : { success: false, error: 'No QR code found' }); } catch (error) { - self.postMessage({ success: false, error: error.message }); + console.error('QR processing error:', error); + self.postMessage({ + success: false, + error: error instanceof Error ? error.message : String(error), + details: { + errorType: error.name, + errorStack: error instanceof Error ? error.stack : undefined, + jsQRType: typeof self.jsQR + } + }); } }; - `], { type: 'text/javascript' }))); - const image = new Image(); - image.crossOrigin = 'anonymous'; - image.src = imageDataUrl; + function rotateImageData(imageData, width, height) { + const canvas = new OffscreenCanvas(height, width); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const newImageData = ctx.createImageData(height, width); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIndex = (y * width + x) * 4; + const destIndex = ((width - x - 1) * height + y) * 4; + newImageData.data[destIndex] = imageData.data[srcIndex]; + newImageData.data[destIndex + 1] = imageData.data[srcIndex + 1]; + newImageData.data[destIndex + 2] = imageData.data[srcIndex + 2]; + newImageData.data[destIndex + 3] = imageData.data[srcIndex + 3]; + } + } + return { data: newImageData.data, width: height, height: width }; + } + `; + + // Terminate existing worker if it exists + if (this.worker) { + logger.log('Terminating existing worker'); + this.worker.terminate(); + this.worker = null; + this.workerInitialized = false; + } + + // Create blob and worker + logger.log('Creating worker blob and URL'); + const blob = new Blob([workerCode], { type: 'application/javascript' }); + const workerUrl = URL.createObjectURL(blob); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Image load timeout')), 5000); - image.onload = () => { - clearTimeout(timeout); - resolve(undefined); + logger.log('Creating new worker'); + this.worker = new Worker(workerUrl); + + // Wait for worker to be ready with increased timeout + await new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error('Worker failed to initialize')); + return; + } + + logger.log('Setting up worker message handlers'); + const timeoutId = setTimeout(() => { + logger.error('Worker initialization timed out after 10 seconds'); + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + reject(new Error('Worker initialization timed out')); + }, 10000); + + this.worker.onmessage = (e) => { + logger.log('Received worker message:', e.data); + if (e.data?.initialized) { + clearTimeout(timeoutId); + this.workerInitialized = true; + resolve(); + } else if (e.data?.error) { + clearTimeout(timeoutId); + reject(new Error(e.data.error)); + } }; - image.onerror = () => { - clearTimeout(timeout); - reject(new Error('Failed to load image')); + + this.worker.onerror = (error) => { + logger.error('Worker error during initialization:', error); + clearTimeout(timeoutId); + reject(new Error(`Worker error: ${error.message}`)); }; }); - logger.log('Image loaded, creating canvas...'); - const canvas = document.createElement('canvas'); - const maxDimension = 1024; // Limit image size for better performance + // Clean up the URL after worker is initialized + URL.revokeObjectURL(workerUrl); + logger.log('Worker initialized successfully'); + this.setProcessingStatus('Ready', 'QR scanner initialized'); - // Scale down image if needed while maintaining aspect ratio - let width = image.naturalWidth || 800; - let height = image.naturalHeight || 600; - if (width > maxDimension || height > maxDimension) { - if (width > height) { - height = Math.floor(height * (maxDimension / width)); - width = maxDimension; - } else { - width = Math.floor(width * (maxDimension / height)); - height = maxDimension; - } - } - - canvas.width = width; - canvas.height = height; + } catch (error) { + logger.error('Failed to initialize worker:', error); + this.setProcessingStatus('Error', `Failed to initialize QR scanner: ${error instanceof Error ? error.message : String(error)}`); - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Failed to get canvas context'); + if (this.worker) { + this.worker.terminate(); + this.worker = null; } + this.workerInitialized = false; - // Draw image maintaining orientation - ctx.save(); - ctx.drawImage(image, 0, 0, width, height); - ctx.restore(); + // Show appropriate error message based on attempt count + if (this.initializationAttempts >= this.MAX_INITIALIZATION_ATTEMPTS) { + this.$notify({ + group: "alert", + type: "error", + title: "QR Scanner Error", + text: "Failed to initialize QR scanner after multiple attempts. Please try again later.", + }); + } else { + this.$notify({ + group: "alert", + type: "warning", + title: "QR Scanner Warning", + text: "Failed to initialize QR scanner. Retrying...", + }); + } + throw error; + } + } - const imageData = ctx.getImageData(0, 0, width, height); + private async loadImage(imageDataUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; - logger.log('Processing image data for QR code...', { - width, - height, - dataLength: imageData.data.length - }); + const loadTimeout = setTimeout(() => { + img.src = ''; + reject(new Error('Image load timeout')); + }, 5000); + + img.onload = () => { + clearTimeout(loadTimeout); + resolve(img); + }; + + img.onerror = () => { + clearTimeout(loadTimeout); + reject(new Error('Failed to load image')); + }; + + img.src = imageDataUrl; + }); + } - // Process QR code in worker - const result = await new Promise((resolve, reject) => { - worker.onmessage = (e: MessageEvent) => { - if (e.data.success) { - resolve(e.data.code); - } else { - reject(new Error(e.data.error)); + private getImageData(img: HTMLImageElement): { imageData: ImageData; width: number; height: number } { + const MAX_IMAGE_DIMENSION = 1024; + const aspectRatio = img.naturalWidth / img.naturalHeight; + + // Calculate dimensions maintaining aspect ratio + let width = MAX_IMAGE_DIMENSION; + let height = MAX_IMAGE_DIMENSION; + + if (aspectRatio > 1) { + height = Math.round(width / aspectRatio); + } else { + width = Math.round(height * aspectRatio); + } + + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + // Clear canvas and ensure proper rendering + ctx.clearRect(0, 0, width, height); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(img, 0, 0, width, height); + + const imageData = ctx.getImageData(0, 0, width, height); + return { imageData, width, height }; + } + + private async processImageForQRCode(imageDataUrl: string): Promise { + try { + if (!this.worker || !this.workerInitialized) { + this.setProcessingStatus('Initializing', 'Setting up QR scanner...'); + try { + await this.initializeWorker(); + } catch (error) { + this.setProcessingStatus('Error', 'QR scanner initialization failed'); + this.$notify({ + group: "alert", + type: "error", + title: "QR Scanner Error", + text: "Failed to initialize QR scanner. Please try again.", + }); + return; + } + } + + this.isProcessing = true; + this.setProcessingStatus('Processing', 'Loading image...'); + + try { + const img = await this.loadImage(imageDataUrl); + const { imageData, width, height } = this.getImageData(img); + + this.setProcessingStatus('Processing', 'Analyzing image for QR code...'); + + const result = await new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error('Worker not initialized')); + return; } - }; - worker.onerror = reject; - worker.postMessage({ - imageData: imageData.data, - width: imageData.width, - height: imageData.height - }); - }); - worker.terminate(); + const timeoutId = setTimeout(() => { + this.setProcessingStatus('Error', 'QR code processing timed out'); + reject(new Error('QR code processing timed out')); + }, 30000); + + this.worker.onmessage = (e: MessageEvent) => { + clearTimeout(timeoutId); + resolve(e.data); + }; + + this.worker.onerror = (error) => { + clearTimeout(timeoutId); + this.setProcessingStatus('Error', `Processing error: ${error.message}`); + reject(error); + }; + + this.setProcessingStatus('Processing', 'Scanning for QR code...'); + this.worker.postMessage({ + imageData: Array.from(imageData.data), + width, + height + }); + }); - if (result) { - logger.log('QR code found:', { data: result.data }); - await this.handleQRCodeResult(result.data); - } else { - logger.log('No QR code found in image'); - this.showError('No QR code found. Please try again.'); + if (result.success && result.code) { + this.setProcessingStatus('Success', 'QR code found!'); + await this.handleQRCodeResult(result.code.data); + } else { + this.setProcessingStatus('Error', result.error || 'No QR code found'); + this.$notify({ + group: "alert", + type: "warning", + title: "No QR Code Found", + text: "Please make sure the QR code is clearly visible and try again.", + }); + } + } catch (error) { + console.error('QR processing error:', error); + this.setProcessingStatus('Error', `Failed to process QR code: ${error instanceof Error ? error.message : String(error)}`); + this.$notify({ + group: "alert", + type: "error", + title: "Processing Error", + text: "Failed to process the image. Please try again.", + }); } - } catch (error) { - logger.error('QR code processing failed:', error); - this.showError('Failed to process QR code. Please try again.'); } finally { - this.cameraActive = false; + this.isProcessing = false; this.isCapturingPhoto = false; this.addCameraState('processing_completed'); + this.addCameraState('capture_completed'); } } @@ -609,9 +938,9 @@ export default class ContactQRScanShow extends Vue { this.danger( "Could not extract contact information from the QR code. Please try again.", "Invalid QR Code", - ); - return; - } + ); + return; + } // Validate JWT format if ( @@ -1057,5 +1386,34 @@ export default class ContactQRScanShow extends Vue { this.addCameraState('capture_completed'); } } + + private setProcessingStatus(status: string, details = '') { + this.isProcessing = status !== 'Error' && status !== 'Success'; + this.processingStatus = status; + this.processingDetails = details; + + // Log the status change + this.logWithDetails('Processing status changed', { + status, + details, + isProcessing: this.isProcessing + }); + } + + private logWithDetails(message: string, details?: Record) { + const formattedDetails = details ? + '\n' + JSON.stringify(details, (key, value) => { + if (value instanceof Error) { + return { + message: value.message, + stack: value.stack, + name: value.name + }; + } + return value; + }, 2) : ''; + + logger.log(`${message}${formattedDetails}`); + } }