diff --git a/android/.gradle/file-system.probe b/android/.gradle/file-system.probe index 888c821c..031fe995 100644 Binary files a/android/.gradle/file-system.probe and b/android/.gradle/file-system.probe differ diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 9a925acf..367d3fa6 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -15,6 +15,7 @@ dependencies { implementation project(':capacitor-filesystem') implementation project(':capacitor-share') implementation project(':capawesome-capacitor-file-picker') + implementation project(':capacitor-mlkit-barcode-scanning') } diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json index dba3e9d8..c3e9a84f 100644 --- a/android/app/src/main/assets/capacitor.config.json +++ b/android/app/src/main/assets/capacitor.config.json @@ -16,6 +16,14 @@ } ] } + }, + "MLKitBarcodeScanner": { + "formats": [ + "QR_CODE" + ], + "detectorSize": 1, + "lensFacing": "back", + "googleBarcodeScannerModuleInstallState": true } } } diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index 6a10948b..bd0cc36a 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -22,5 +22,9 @@ { "pkg": "@capawesome/capacitor-file-picker", "classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin" + }, + { + "pkg": "@capacitor-mlkit/barcode-scanning", + "classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin" } ] diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 77dd6f02..028232bd 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -19,3 +19,6 @@ project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/sh include ':capawesome-capacitor-file-picker' project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android') + +include ':capacitor-mlkit-barcode-scanning' +project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index f5e0e126..e1faa214 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -18,6 +18,12 @@ const config: CapacitorConfig = { } ] } + }, + MLKitBarcodeScanner: { + formats: ['QR_CODE'], // Only enable QR code scanning to improve performance + detectorSize: 1.0, // Use full camera view for detection + lensFacing: 'back', // Default to back camera + googleBarcodeScannerModuleInstallState: true // Enable Google Play Services barcode module installation if needed } } }; diff --git a/ios/App/Podfile b/ios/App/Podfile index 14cdb5b7..bb78ca30 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -12,6 +12,12 @@ def capacitor_pods pod 'Capacitor', :path => '../../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; } }