From 56f945d29f0a4cbb15902e56ee0a8f82f14e4744 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Wed, 16 Apr 2025 07:42:48 +0000 Subject: [PATCH] refactor(qr): streamline barcode scanner implementation - Remove legacy QR code processing with web workers - Simplify camera permission handling and initialization - Improve error handling and state management - Update to use MLKit Barcode Scanner API consistently - Fix type safety issues with error handling - Remove unused camera and worker-related code - Consolidate scanning logic into cleaner methods The changes focus on using the native MLKit scanner capabilities instead of the previous web worker implementation, resulting in more reliable QR code scanning and better error handling. --- android/.gradle/file-system.probe | Bin 8 -> 8 bytes android/app/capacitor.build.gradle | 1 + .../app/src/main/assets/capacitor.config.json | 8 + .../src/main/assets/capacitor.plugins.json | 4 + android/capacitor.settings.gradle | 3 + capacitor.config.ts | 6 + ios/App/Podfile | 6 + package-lock.json | 23 +- package.json | 3 +- src/types/jsqr.d.ts | 8 +- src/views/ContactQRScanShowView.vue | 996 +++++------------- 11 files changed, 295 insertions(+), 763 deletions(-) diff --git a/android/.gradle/file-system.probe b/android/.gradle/file-system.probe index 888c821c38f6368f754d01054c39f96d4db34492..031fe995cd22e477752eb54322513d80db5ae724 100644 GIT binary patch literal 8 PcmZQzV4P;Fvt0-P1@r;R literal 8 PcmZQzV4P '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' + pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera' + pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard' + pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' + pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' + pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker' + pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning' end target 'App' do diff --git a/package-lock.json b/package-lock.json index 30044347..01bea205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@capacitor/camera": "^6.0.0", "@capacitor/cli": "^6.2.0", "@capacitor/clipboard": "^6.0.2", - "@capacitor/core": "^6.2.0", + "@capacitor/core": "^6.2.1", "@capacitor/filesystem": "^6.0.0", "@capacitor/ios": "^6.2.0", "@capacitor/share": "^6.0.3", @@ -89,6 +89,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@capacitor-mlkit/barcode-scanning": "^6.2.0", "@capacitor/assets": "^3.0.5", "@playwright/test": "^1.45.2", "@types/dom-webcodecs": "^0.1.7", @@ -2607,6 +2608,26 @@ "node": ">=8.9" } }, + "node_modules/@capacitor-mlkit/barcode-scanning": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@capacitor-mlkit/barcode-scanning/-/barcode-scanning-6.2.0.tgz", + "integrity": "sha512-XnnErDabpCUty9flugqB646ERejCxrtKcKOJrdoh9ZVLTQXUnyjxUDWOlqHVxrBHy+e86ZgpZX7D5zcaNvS0lQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/capawesome-team/" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/capawesome" + } + ], + "license": "Apache-2.0", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/@capacitor/android": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz", diff --git a/package.json b/package.json index 1da68e05..bb5cd15a 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@capacitor/camera": "^6.0.0", "@capacitor/cli": "^6.2.0", "@capacitor/clipboard": "^6.0.2", - "@capacitor/core": "^6.2.0", + "@capacitor/core": "^6.2.1", "@capacitor/filesystem": "^6.0.0", "@capacitor/ios": "^6.2.0", "@capacitor/share": "^6.0.3", @@ -128,6 +128,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@capacitor-mlkit/barcode-scanning": "^6.2.0", "@capacitor/assets": "^3.0.5", "@playwright/test": "^1.45.2", "@types/dom-webcodecs": "^0.1.7", diff --git a/src/types/jsqr.d.ts b/src/types/jsqr.d.ts index 3a29179d..651c66a9 100644 --- a/src/types/jsqr.d.ts +++ b/src/types/jsqr.d.ts @@ -1,4 +1,4 @@ -declare module 'jsqr' { +declare module "jsqr" { interface Point { x: number; y: number; @@ -26,9 +26,9 @@ declare module 'jsqr' { width: number, height: number, options?: { - inversionAttempts?: 'dontInvert' | 'onlyInvert' | 'attemptBoth'; - } + inversionAttempts?: "dontInvert" | "onlyInvert" | "attemptBoth"; + }, ): QRCode | null; export default jsQR; -} \ No newline at end of file +} diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 3807029e..07f40ff0 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -3,11 +3,16 @@
-
+
-
-

{{ processingStatus }}

-

{{ processingDetails }}

+
+

{{ state.processingStatus }}

+

{{ state.processingDetails }}

@@ -122,55 +127,54 @@ import { db } from "../db/index"; import { Contact } from "../db/tables/contacts"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { getContactJwtFromJwtUrl } from "../libs/crypto"; -import { - isDid, - register, - setVisibilityUtil, -} from "../libs/endorserServer"; +import { isDid, register, setVisibilityUtil } from "../libs/endorserServer"; import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; import { Router } from "vue-router"; import { logger } from "../utils/logger"; -import { Camera, CameraResultType, CameraSource, ImageOptions, CameraPermissionState } from '@capacitor/camera'; -import { App } from '@capacitor/app'; -import jsQR from "jsqr"; +import { App } from "@capacitor/app"; +import type { PluginListenerHandle } from "@capacitor/core"; +import { + BarcodeScanner, + type ScanResult, +} from "@capacitor-mlkit/barcode-scanning"; +import { ref, type Ref, reactive } from "vue"; // Declare global constants declare const __USE_QR_READER__: boolean; declare const __IS_MOBILE__: boolean; - // Define all possible camera states -type CameraState = - | 'initializing' - | 'ready' - | 'error' - | 'checking_permissions' - | 'no_camera_capability' - | 'permission_status_checked' - | 'requesting_permission' - | 'permission_requested' - | 'permission_check_error' - | 'camera_initialized' - | 'camera_error' - | 'opening_camera' - | 'capture_already_in_progress' - | 'photo_captured' - | 'processing_photo' - | 'no_image_data' - | 'capture_error' - | 'user_cancelled' - | 'permission_error' - | 'hardware_unavailable' - | 'capture_completed' - | 'cleanup' - | 'processing_completed'; +type CameraState = + | "initializing" + | "ready" + | "error" + | "checking_permissions" + | "no_camera_capability" + | "permission_status_checked" + | "requesting_permission" + | "permission_requested" + | "permission_check_error" + | "camera_initialized" + | "camera_error" + | "opening_camera" + | "capture_already_in_progress" + | "photo_captured" + | "processing_photo" + | "no_image_data" + | "capture_error" + | "user_cancelled" + | "permission_error" + | "hardware_unavailable" + | "capture_completed" + | "cleanup" + | "processing_completed"; // Define all possible QR processing states -type QRProcessingState = - | 'processing_image' - | 'qr_code_detected' - | 'no_qr_code_found' - | 'processing_error'; +type QRProcessingState = + | "processing_image" + | "qr_code_detected" + | "no_qr_code_found" + | "processing_error"; interface CameraStateHistoryEntry { state: CameraState | QRProcessingState; @@ -178,62 +182,38 @@ interface CameraStateHistoryEntry { details?: Record; } - -// Custom AppState type to match our needs -interface CustomAppState { - state: 'active' | 'inactive' | 'background' | 'foreground'; -} - interface AppStateChangeEvent { isActive: boolean; } -// Define worker message types -interface QRCodeResult { - data: string; - location: { - topLeftCorner: { x: number; y: number }; - topRightCorner: { x: number; y: number }; - bottomRightCorner: { x: number; y: number }; - bottomLeftCorner: { x: number; y: number }; - }; -} - -interface WorkerSuccessMessage { - success: true; - code: QRCodeResult; -} - -interface WorkerErrorMessage { - success: false; +interface ScannerState { + isSupported: boolean; + granted: boolean; + denied: boolean; + isProcessing: boolean; + processingStatus: string; + processingDetails: string; error: string; + status: string; } -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; +interface AppState { + isProcessing: boolean; + processingStatus: string; + processingDetails: string; + error: string; + scannerState: ScannerState; } @Component({ components: { - QrcodeStream: __USE_QR_READER__ ? QrcodeStream : null, QRCodeVue3, + QrcodeStream, QuickNav, UserNameDialog, }, }) -export default class ContactQRScanShow extends Vue { +export default class ContactQRScanShowView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; $router!: Router; declare $refs: { @@ -255,23 +235,32 @@ export default class ContactQRScanShow extends Vue { PlatformServiceFactory.getInstance(); private cameraActive = false; - private lastCameraState: CameraState | QRProcessingState = 'initializing'; + private lastCameraState: CameraState | QRProcessingState = "initializing"; private cameraStateHistory: CameraStateHistoryEntry[] = []; private readonly STATE_HISTORY_LIMIT = 20; 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; + private scanListener: Ref = ref(null); + private state = reactive({ + isProcessing: false, + processingStatus: "", + processingDetails: "", + error: "", + scannerState: { + isSupported: false, + granted: false, + denied: false, + isProcessing: false, + processingStatus: "", + processingDetails: "", + error: "", + status: "", + }, + }); async created() { - logger.log('ContactQRScanShow component created'); + logger.log("ContactQRScanShow component created"); try { // Remove any existing listeners first await this.cleanupAppListeners(); @@ -281,11 +270,20 @@ export default class ContactQRScanShow extends Vue { // Load initial data await this.loadInitialData(); - - logger.log('ContactQRScanShow initialization complete'); + + // Check if barcode scanning is supported + const { supported } = await BarcodeScanner.isSupported(); + if (!supported) { + this.showError("Barcode scanning is not supported on this device"); + return; + } + + // Initialize scanner state but don't request permissions yet + this.state.scannerState.isSupported = supported; + logger.log("ContactQRScanShow initialization complete"); } catch (error) { - logger.error('Failed to initialize ContactQRScanShow:', error); - this.showError('Failed to initialize. Please try again.'); + logger.error("Failed to initialize ContactQRScanShow:", error); + this.showError("Failed to initialize. Please try again."); } } @@ -296,9 +294,9 @@ export default class ContactQRScanShow extends Vue { this.appStateListener = undefined; } await App.removeAllListeners(); - logger.log('App listeners cleaned up successfully'); + logger.log("App listeners cleaned up successfully"); } catch (error) { - logger.error('Error cleaning up app listeners:', error); + logger.error("Error cleaning up app listeners:", error); throw error; } } @@ -306,29 +304,32 @@ export default class ContactQRScanShow extends Vue { 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.cleanupCamera(); - } - }); + this.appStateListener = await App.addListener( + "appStateChange", + (state: AppStateChangeEvent) => { + logger.log("App state changed:", state); + if (!state.isActive) { + this.cleanupCamera(); + } + }, + ); // Add pause listener - await App.addListener('pause', () => { - logger.log('App paused'); + await App.addListener("pause", () => { + logger.log("App paused"); this.cleanupCamera(); }); // Add resume listener - await App.addListener('resume', () => { - logger.log('App resumed'); + await App.addListener("resume", () => { + logger.log("App resumed"); // Don't automatically reinitialize camera on resume // Let user explicitly request camera access again }); - logger.log('App lifecycle listeners setup complete'); + logger.log("App lifecycle listeners setup complete"); } catch (error) { - logger.error('Error setting up app lifecycle listeners:', error); + logger.error("Error setting up app lifecycle listeners:", error); throw error; } } @@ -339,11 +340,12 @@ export default class ContactQRScanShow extends Vue { await db.open(); const settings = await db.settings.get(MASTER_SETTINGS_KEY); if (settings) { - this.hideRegisterPromptOnNewContact = settings.hideRegisterPromptOnNewContact || false; + this.hideRegisterPromptOnNewContact = + settings.hideRegisterPromptOnNewContact || false; } - logger.log('Initial data loaded successfully'); + logger.log("Initial data loaded successfully"); } catch (error) { - logger.error('Failed to load initial data:', error); + logger.error("Failed to load initial data:", error); throw error; } } @@ -353,15 +355,18 @@ export default class ContactQRScanShow extends Vue { if (this.cameraActive) { this.cameraActive = false; this.isCapturingPhoto = false; - this.addCameraState('cleanup'); - logger.log('Camera cleaned up successfully'); + this.addCameraState("cleanup"); + logger.log("Camera cleaned up successfully"); } } catch (error) { - logger.error('Error during camera cleanup:', error); + logger.error("Error during camera cleanup:", error); } } - private addCameraState(state: CameraState | QRProcessingState, details?: Record) { + private addCameraState( + state: CameraState | QRProcessingState, + details?: Record, + ) { // Prevent duplicate state transitions if (this.lastCameraState === state) { return; @@ -374,29 +379,32 @@ export default class ContactQRScanShow extends Vue { ...details, cameraActive: this.cameraActive, isCapturingPhoto: this.isCapturingPhoto, - isProcessing: this.isProcessing - } + isProcessing: this.state.isProcessing, + }, }; - + this.cameraStateHistory.push(entry); - + if (this.cameraStateHistory.length > this.STATE_HISTORY_LIMIT) { this.cameraStateHistory.shift(); } - - this.logWithDetails('Camera state transition', { + + this.logWithDetails("Camera state transition", { state: entry.state, timestamp: entry.timestamp, - details: entry.details + details: entry.details, }); this.lastCameraState = state; } beforeDestroy() { - logger.log('ContactQRScanShow component being destroyed, initiating cleanup', { - cameraActive: this.cameraActive, - lastState: this.lastCameraState, - stateHistory: this.cameraStateHistory + logger.log( + "ContactQRScanShow component being destroyed, initiating cleanup", + ); + + // Clean up scanner + this.stopScanning().catch((error) => { + logger.error("Error stopping scanner during destroy:", error); }); // Remove all app lifecycle listeners @@ -404,498 +412,113 @@ export default class ContactQRScanShow extends Vue { if (this.appStateListener) { try { await this.appStateListener.remove(); - logger.log('App state change listener removed successfully'); + logger.log("App state change listener removed successfully"); } catch (error) { - logger.error('Error removing app state change listener:', error); + logger.error("Error removing app state change listener:", error); } } try { await App.removeAllListeners(); - logger.log('All app listeners removed successfully'); + logger.log("All app listeners removed successfully"); } catch (error) { - logger.error('Error removing all app listeners:', error); + logger.error("Error removing all app listeners:", error); } }; // Cleanup everything - Promise.all([ - cleanupListeners(), - this.cleanupCamera() - ]).catch(error => { - logger.error('Error during component cleanup:', error); - }); - - if (this.worker) { - this.worker.terminate(); - this.worker = null; - } - } - - private async handleQRCodeResult(data: string): Promise { - try { - await this.onScanDetect([{ rawValue: data }]); - } catch (error) { - console.error('Failed to handle QR code result:', error); - this.showError('Failed to process QR code data.'); - } - } - - async initializeCamera(retryCount = 0): Promise { - if (this.cameraActive) { - console.log('Camera already active, skipping initialization'); - return; - } - - try { - console.log('Initializing camera...', { retryCount }); - - // Check camera permissions first - const permissionStatus = await this.checkCameraPermission(); - if (permissionStatus.camera !== 'granted') { - throw new Error('Camera permission not granted'); - } - - this.cameraActive = true; - - // Configure camera options - const cameraOptions: ImageOptions = { - quality: 90, - allowEditing: false, - resultType: CameraResultType.DataUrl, - source: CameraSource.Camera - }; - - console.log('Opening camera with options:', cameraOptions); - const image = await Camera.getPhoto(cameraOptions); - - if (!image || !image.dataUrl) { - throw new Error('Failed to capture photo: No image data received'); - } - - console.log('Photo captured successfully, processing image...'); - await this.processImageForQRCode(image.dataUrl); - - } catch (error) { - this.cameraActive = false; - console.error('Camera initialization failed:', error instanceof Error ? error.message : String(error)); - - // Handle user cancellation separately - if (error instanceof Error && error.message.includes('User cancelled photos app')) { - console.log('User cancelled photo capture'); - return; - } - - // Handle retry logic - if (retryCount < 2) { - console.log('Retrying camera initialization...'); - await new Promise(resolve => setTimeout(resolve, 1000)); - return this.initializeCamera(retryCount + 1); - } - - this.showError('Failed to initialize camera. Please try again.'); - } - } - - async checkCameraPermission(): Promise<{ camera: CameraPermissionState }> { - try { - this.addCameraState('checking_permissions'); - const capabilities = this.platformService.getCapabilities(); - if (!capabilities.hasCamera) { - this.addCameraState('no_camera_capability'); - return { camera: 'denied' as CameraPermissionState }; - } - - const permissionStatus = await Camera.checkPermissions(); - this.addCameraState('permission_status_checked'); - - if (permissionStatus.camera === 'prompt') { - this.addCameraState('requesting_permission'); - const requestResult = await Camera.requestPermissions(); - this.addCameraState('permission_requested'); - return requestResult; - } - - return permissionStatus; - } catch (error) { - this.addCameraState('permission_check_error'); - return { camera: 'denied' as CameraPermissionState }; - } - } - - private async initializeWorker(): Promise { - try { - 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 }); - - // 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 { - const uint8Array = new Uint8ClampedArray(imageData); - - // Try normal orientation - let code = self.jsQR(uint8Array, width, height, { - inversionAttempts: "attemptBoth" - }); - - 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) { - 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 - } - }); - } - }; - - 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); - - 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)); - } - }; - - this.worker.onerror = (error) => { - logger.error('Worker error during initialization:', error); - clearTimeout(timeoutId); - reject(new Error(`Worker error: ${error.message}`)); - }; - }); - - // Clean up the URL after worker is initialized - URL.revokeObjectURL(workerUrl); - logger.log('Worker initialized successfully'); - this.setProcessingStatus('Ready', 'QR scanner initialized'); - - } catch (error) { - logger.error('Failed to initialize worker:', error); - this.setProcessingStatus('Error', `Failed to initialize QR scanner: ${error instanceof Error ? error.message : String(error)}`); - - if (this.worker) { - this.worker.terminate(); - this.worker = null; - } - this.workerInitialized = false; - - // 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; - } - } - - private async loadImage(imageDataUrl: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - - 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; + Promise.all([cleanupListeners(), this.cleanupCamera()]).catch((error) => { + logger.error("Error during component cleanup:", 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 { + async openMobileCamera() { 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.state.isProcessing = true; + this.state.processingStatus = "Starting camera..."; + + // Check current permission status + const status = await BarcodeScanner.checkPermissions(); + + if (status.camera !== "granted") { + // Request permission if not granted + const permissionStatus = await BarcodeScanner.requestPermissions(); + if (permissionStatus.camera !== "granted") { + throw new Error("Camera permission not granted"); } } - this.isProcessing = true; - this.setProcessingStatus('Processing', 'Loading image...'); - + // Set up the listener before starting the scan 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; - } + const listener = await BarcodeScanner.addListener( + "barcodesScanned", + async (result: ScanResult) => { + if (result.barcodes && result.barcodes.length > 0) { + this.state.processingDetails = `Processing QR code: ${result.barcodes[0].rawValue}`; + await this.handleScanResult(result.barcodes[0].rawValue); + } + }, + ); - 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.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.", - }); + // Only set the listener if we successfully got one + if (listener) { + this.scanListener.value = listener; } } 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.", - }); + logger.error("Error setting up barcode listener:", error); + throw new Error("Failed to initialize barcode scanner"); } - } finally { - this.isProcessing = false; - this.isCapturingPhoto = false; - this.addCameraState('processing_completed'); - this.addCameraState('capture_completed'); + + // Start the scanner + await BarcodeScanner.startScan(); + this.state.isProcessing = false; + this.state.processingStatus = ""; + } catch (error) { + logger.error("Failed to open camera:", error); + this.state.isProcessing = false; + this.state.processingStatus = ""; + this.state.scannerState.status = "error"; + this.showError( + error instanceof Error ? error.message : "Failed to open camera", + ); } } + private async handleScanResult(rawValue: string) { + this.state.isProcessing = true; + this.state.processingStatus = "Processing QR code..."; + this.state.processingDetails = `Scanned value: ${rawValue}`; + try { + await this.stopScanning(); + await this.onScanDetect({ rawValue }); + } catch (error) { + logger.error("Error handling scan result:", error); + this.showError("Failed to process scan result"); + } + } + + 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}`); + } + danger(message: string, title = "Error", timeout = 5000): void { this.$notify( { @@ -938,9 +561,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 ( @@ -1230,64 +853,6 @@ export default class ContactQRScanShow extends Vue { } } - async requestCameraPermission(): Promise { - try { - const capabilities = this.platformService.getCapabilities(); - if (capabilities.hasCamera) { - try { - await this.platformService.takePicture(); - this.$notify( - { - group: "alert", - type: "success", - title: "Camera Access Granted", - text: "You can now scan QR codes.", - }, - 3000, - ); - } catch (error) { - this.$notify( - { - group: "alert", - type: "danger", - title: "Camera Access Denied", - text: "Please enable camera access in your device settings.", - }, - 5000, - ); - } - } - } catch (error) { - this.$notify( - { - group: "alert", - type: "danger", - title: "Error", - text: "Failed to request camera permission.", - }, - 3000, - ); - } - } - - async onCancel(stopAsking?: boolean) { - if (stopAsking) { - await db.settings.update(MASTER_SETTINGS_KEY, { - hideRegisterPromptOnNewContact: stopAsking, - }); - this.hideRegisterPromptOnNewContact = stopAsking; - } - } - - async onNo(stopAsking?: boolean) { - if (stopAsking) { - await db.settings.update(MASTER_SETTINGS_KEY, { - hideRegisterPromptOnNewContact: stopAsking, - }); - this.hideRegisterPromptOnNewContact = stopAsking; - } - } - get useQRReader(): boolean { return __USE_QR_READER__; } @@ -1297,8 +862,11 @@ export default class ContactQRScanShow extends Vue { } private showError(message: string) { - // Implement the logic to show a user-friendly error message to the user - console.error(message); + this.state.error = message; + this.state.scannerState.error = message; + this.state.scannerState.status = "error"; + // You might want to show this in your UI or use a toast notification + logger.error(message); this.$notify( { group: "alert", @@ -1310,110 +878,24 @@ export default class ContactQRScanShow extends Vue { ); } - async openMobileCamera() { - if (this.isCapturingPhoto) { - this.addCameraState('capture_already_in_progress'); - logger.warn('Camera capture already in progress, ignoring request'); - return; - } - + async stopScanning() { try { - this.isCapturingPhoto = true; - this.addCameraState('opening_camera'); - - const config = { - quality: 90, - allowEditing: false, - resultType: CameraResultType.DataUrl, - source: CameraSource.Camera - }; - - logger.log('Camera configuration:', config); - - const image = await Camera.getPhoto(config); - - this.addCameraState('photo_captured', { - hasDataUrl: !!image.dataUrl, - dataUrlLength: image.dataUrl?.length, - format: image.format, - saved: image.saved, - webPath: image.webPath, - base64String: image.dataUrl?.substring(0, 50) + '...' // Log first 50 chars of base64 - }); - - if (image.dataUrl) { - this.addCameraState('processing_photo'); - await this.processImageForQRCode(image.dataUrl); - } else { - this.addCameraState('no_image_data'); - logger.error('Camera returned no image data'); - this.$notify({ - type: 'error', - title: 'Camera Error', - text: 'No image was captured. Please try again.', - group: 'qr-scanner' - }); - } - } catch (error) { - this.addCameraState('capture_error', { - error, - errorName: error instanceof Error ? error.name : 'Unknown', - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined - }); - - if (error instanceof Error) { - if (error.message.includes('User cancelled photos app')) { - logger.log('User cancelled photo capture'); - this.addCameraState('user_cancelled'); - } else if (error.message.includes('permission')) { - logger.error('Camera permission error during capture'); - this.addCameraState('permission_error'); - } else if (error.message.includes('Camera is not available')) { - logger.error('Camera hardware not available'); - this.addCameraState('hardware_unavailable'); - } - } - - this.$notify({ - type: 'error', - title: 'Camera Error', - text: 'Failed to capture photo. Please check camera permissions and try again.', - group: 'qr-scanner' - }); - } finally { - this.isCapturingPhoto = false; - this.addCameraState('capture_completed'); + await BarcodeScanner.stopScan(); + this.state.scannerState.processingStatus = "Scan stopped"; + this.state.scannerState.isProcessing = false; + this.state.scannerState.processingDetails = ""; + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.state.scannerState.error = `Error stopping scan: ${errorMessage}`; + this.state.scannerState.isProcessing = false; } } - 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}`); + private async handleError(error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.state.error = errorMessage; + this.state.scannerState.error = errorMessage; } }