From a8812714a343cebc02d6945e12b00953c803c2e1 Mon Sep 17 00:00:00 2001 From: Matthew Raymer <matthew.raymer@anomalistdesign.com> Date: Tue, 22 Apr 2025 10:00:37 +0000 Subject: [PATCH] fix(qr): improve QR scanner implementation and error handling - Implement robust QR scanner factory with platform detection - Add proper camera permissions to Android manifest - Improve error handling and logging across scanner implementations - Add continuous scanning mode for Capacitor/MLKit scanner - Enhance UI feedback during scanning process - Fix build configuration for proper platform detection - Clean up resources properly in scanner components - Add TypeScript improvements and error wrapping The changes include: - Adding CAMERA permission to AndroidManifest.xml - Setting proper build flags (__IS_MOBILE__, __USE_QR_READER__) - Implementing continuous scanning mode for better UX - Adding proper cleanup of scanner resources - Improving error handling and type safety - Enhancing UI with loading states and error messages --- .../rules/qr-code-implementation-guide.mdc | 578 ++++++++---------- android/.gradle/file-system.probe | Bin 8 -> 8 bytes android/app/src/main/AndroidManifest.xml | 2 + package.json | 5 +- src/components/QRScanner/QRScannerDialog.vue | 26 +- src/services/QRScanner/CapacitorQRScanner.ts | 65 +- src/services/QRScanner/QRScannerFactory.ts | 53 +- src/services/QRScanner/WebDialogQRScanner.ts | 58 +- src/views/ContactQRScanShowView.vue | 194 +++--- vite.config.common.mts | 2 + 10 files changed, 540 insertions(+), 443 deletions(-) diff --git a/.cursor/rules/qr-code-implementation-guide.mdc b/.cursor/rules/qr-code-implementation-guide.mdc index 0ba19bff..0f0a9b21 100644 --- a/.cursor/rules/qr-code-implementation-guide.mdc +++ b/.cursor/rules/qr-code-implementation-guide.mdc @@ -133,289 +133,61 @@ export class CapacitorQRScanner implements QRScannerService { } } - // Implement other interface methods... -} -``` - -5. **Implement Web Scanner** -```typescript -// WebDialogQRScanner.ts -export class WebDialogQRScanner implements QRScannerService { - private dialogInstance: App | null = null; - private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null; - private scanListener: ScanListener | null = null; - async checkPermissions(): Promise<boolean> { + async requestPermissions() { try { - const permissions = await navigator.permissions.query({ - name: 'camera' as PermissionName - }); - return permissions.state === 'granted'; + const { camera } = await BarcodeScanner.requestPermissions(); + return camera === 'granted'; } catch (error) { - logger.error('Error checking camera permissions:', error); + logger.error('Error requesting camera permissions:', error); return false; } } - // Implement other interface methods... -} -``` - -6. **Create Dialog Component** -```vue -<!-- QRScannerDialog.vue --> -<template> - <div v-if="visible" class="dialog-overlay z-[60]"> - <div class="dialog relative"> - <!-- Dialog content --> - <div v-if="useQRReader"> - <qrcode-stream - class="w-full max-w-lg mx-auto" - @detect="onScanDetect" - @error="onScanError" - /> - </div> - <div v-else> - <!-- Mobile camera button --> - </div> - </div> - </div> -</template> - -<script lang="ts"> -@Component({ - components: { QrcodeStream } -}) -export default class QRScannerDialog extends Vue { - // Implementation... -} -</script> -``` - -## Usage Example - -```typescript -// In your component -async function scanQRCode() { - const scanner = QRScannerFactory.getInstance(); - - if (!(await scanner.checkPermissions())) { - const granted = await scanner.requestPermissions(); - if (!granted) { - throw new Error('Camera permission denied'); - } + async isSupported() { + return Capacitor.isNativePlatform(); } - scanner.addListener({ - onScan: (result) => { - console.log('Scanned:', result); - }, - onError: (error) => { - console.error('Scan error:', error); - } - }); - - await scanner.startScan(); -} - -// Cleanup when done -onUnmounted(() => { - QRScannerFactory.cleanup(); -}); -``` - -## Platform-Specific Notes - -### Mobile (Capacitor) -- Uses MLKit for optimal performance -- Handles native permissions -- Supports both iOS and Android -- Uses back camera by default -- Handles device rotation - -### Web -- Uses MediaDevices API -- Requires HTTPS for camera access -- Handles browser compatibility -- Manages memory and resources -- Provides fallback UI - -## Testing - -1. **Unit Tests** -- Test factory pattern -- Test platform detection -- Test error handling -- Test cleanup procedures - -2. **Integration Tests** -- Test permission flows -- Test camera access -- Test QR code detection -- Test cross-platform behavior - -3. **E2E Tests** -- Test full scanning workflow -- Test UI feedback -- Test error scenarios -- Test platform differences - -## Common Issues and Solutions - -1. **Permission Handling** -- Always check permissions first -- Provide clear user feedback -- Handle denial gracefully -- Implement retry logic - -2. **Resource Management** -- Clean up after scanning -- Handle component unmounting -- Release camera resources -- Clear event listeners - -3. **Error Handling** -- Log errors appropriately -- Provide user feedback -- Implement fallbacks -- Handle edge cases - -4. **Performance** -- Optimize camera preview -- Handle memory usage -- Manage battery impact -- Consider device capabilities - -# QR Code Implementation Guide - -## Directory Structure - -``` -src/ -├── components/ -│ └── QRScanner/ -│ ├── types.ts -│ ├── factory.ts -│ ├── CapacitorScanner.ts -│ ├── WebDialogScanner.ts -│ └── QRScannerDialog.vue -├── services/ -│ └── QRScanner/ -│ ├── types.ts -│ ├── QRScannerFactory.ts -│ ├── CapacitorQRScanner.ts -│ └── WebDialogQRScanner.ts -``` - -## Core Interfaces - -```typescript -// types.ts -export interface ScanListener { - onScan: (result: string) => void; - onError?: (error: Error) => void; -} - -export interface QRScannerService { - checkPermissions(): Promise<boolean>; - requestPermissions(): Promise<boolean>; - isSupported(): Promise<boolean>; - startScan(): Promise<void>; - stopScan(): Promise<void>; - addListener(listener: ScanListener): void; - cleanup(): Promise<void>; -} -``` - -## Configuration Files - -### Vite Configuration -```typescript -// vite.config.ts -export default defineConfig({ - define: { - __USE_QR_READER__: JSON.stringify(!isMobile), - __IS_MOBILE__: JSON.stringify(isMobile), - }, - build: { - rollupOptions: { - external: isMobile ? ['vue-qrcode-reader'] : [], - } - } -}); -``` + async startScan() { + if (this.isScanning) return; + this.isScanning = true; -### Capacitor Configuration -```typescript -// capacitor.config.ts -const config: CapacitorConfig = { - plugins: { - MLKitBarcodeScanner: { - formats: ['QR_CODE'], - detectorSize: 1.0, - lensFacing: 'back', - googleBarcodeScannerModuleInstallState: true + try { + await BarcodeScanner.startScan(); + } catch (error) { + this.isScanning = false; + throw error; } } -}; -``` - -## Implementation Steps - -1. **Install Dependencies** -```bash -npm install @capacitor-mlkit/barcode-scanning vue-qrcode-reader -``` -2. **Create Core Types** -Create the interface files as shown above. - -3. **Implement Factory** -```typescript -// QRScannerFactory.ts -export class QRScannerFactory { - private static instance: QRScannerService | null = null; + async stopScan() { + if (!this.isScanning) return; + this.isScanning = false; - static getInstance(): QRScannerService { - if (!this.instance) { - if (__IS_MOBILE__ || Capacitor.isNativePlatform()) { - this.instance = new CapacitorQRScanner(); - } else if (__USE_QR_READER__) { - this.instance = new WebDialogQRScanner(); - } else { - throw new Error('No QR scanner implementation available'); - } + try { + await BarcodeScanner.stopScan(); + } catch (error) { + logger.error('Error stopping scan:', error); } - return this.instance; } - static async cleanup() { - if (this.instance) { - await this.instance.cleanup(); - this.instance = null; - } + addListener(listener: ScanListener) { + this.scanListener = listener; + const handle = BarcodeScanner.addListener('barcodeScanned', (result) => { + if (this.scanListener) { + this.scanListener.onScan(result.barcode); + } + }); + this.listenerHandles.push(handle.remove); } -} -``` -4. **Implement Mobile Scanner** -```typescript -// CapacitorQRScanner.ts -export class CapacitorQRScanner implements QRScannerService { - private scanListener: ScanListener | null = null; - private isScanning = false; - private listenerHandles: Array<() => Promise<void>> = []; - - async checkPermissions() { - try { - const { camera } = await BarcodeScanner.checkPermissions(); - return camera === 'granted'; - } catch (error) { - logger.error('Error checking camera permissions:', error); - return false; + async cleanup() { + await this.stopScan(); + for (const handle of this.listenerHandles) { + await handle(); } + this.listenerHandles = []; + this.scanListener = null; } - - // Implement other interface methods... } ``` @@ -426,6 +198,7 @@ export class WebDialogQRScanner implements QRScannerService { private dialogInstance: App | null = null; private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null; private scanListener: ScanListener | null = null; + async checkPermissions(): Promise<boolean> { try { const permissions = await navigator.permissions.query({ @@ -438,7 +211,61 @@ export class WebDialogQRScanner implements QRScannerService { } } - // Implement other interface methods... + async requestPermissions(): Promise<boolean> { + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + stream.getTracks().forEach(track => track.stop()); + return true; + } catch (error) { + logger.error('Error requesting camera permissions:', error); + return false; + } + } + + async isSupported(): Promise<boolean> { + return 'mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices; + } + + async startScan() { + if (this.dialogInstance) return; + + const container = document.createElement('div'); + document.body.appendChild(container); + + this.dialogInstance = createApp(QRScannerDialog, { + onScan: (result: string) => { + if (this.scanListener) { + this.scanListener.onScan(result); + } + }, + onError: (error: Error) => { + if (this.scanListener?.onError) { + this.scanListener.onError(error); + } + }, + onClose: () => { + this.cleanup(); + } + }); + + this.dialogComponent = this.dialogInstance.mount(container) as InstanceType<typeof QRScannerDialog>; + } + + async stopScan() { + await this.cleanup(); + } + + addListener(listener: ScanListener) { + this.scanListener = listener; + } + + async cleanup() { + if (this.dialogInstance) { + this.dialogInstance.unmount(); + this.dialogInstance = null; + this.dialogComponent = null; + } + } } ``` @@ -448,29 +275,125 @@ export class WebDialogQRScanner implements QRScannerService { <template> <div v-if="visible" class="dialog-overlay z-[60]"> <div class="dialog relative"> - <!-- Dialog content --> - <div v-if="useQRReader"> - <qrcode-stream - class="w-full max-w-lg mx-auto" - @detect="onScanDetect" - @error="onScanError" - /> + <div class="dialog-header"> + <h2>Scan QR Code</h2> + <button @click="onClose" class="close-button">×</button> </div> - <div v-else> - <!-- Mobile camera button --> + <div class="dialog-content"> + <div v-if="useQRReader"> + <qrcode-stream + class="w-full max-w-lg mx-auto" + @detect="onScanDetect" + @error="onScanError" + /> + </div> + <div v-else> + <button @click="startMobileScan" class="scan-button"> + Start Camera + </button> + </div> </div> </div> </div> </template> <script lang="ts"> -@Component({ - components: { QrcodeStream } -}) -export default class QRScannerDialog extends Vue { - // Implementation... -} +import { defineComponent } from 'vue'; +import { QrcodeStream } from 'vue-qrcode-reader'; + +export default defineComponent({ + name: 'QRScannerDialog', + components: { QrcodeStream }, + props: { + onScan: { + type: Function, + required: true + }, + onError: { + type: Function, + required: true + }, + onClose: { + type: Function, + required: true + } + }, + data() { + return { + visible: true, + useQRReader: __USE_QR_READER__ + }; + }, + methods: { + onScanDetect(promisedResult: Promise<string>) { + promisedResult + .then(result => this.onScan(result)) + .catch(error => this.onError(error)); + }, + onScanError(error: Error) { + this.onError(error); + }, + async startMobileScan() { + try { + const scanner = QRScannerFactory.getInstance(); + await scanner.startScan(); + } catch (error) { + this.onError(error as Error); + } + } + } +}); </script> + +<style scoped> +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; +} + +.dialog { + background: white; + border-radius: 8px; + padding: 20px; + max-width: 90vw; + max-height: 90vh; + overflow: auto; +} + +.dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.close-button { + background: none; + border: none; + font-size: 24px; + cursor: pointer; +} + +.scan-button { + background: #4CAF50; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.scan-button:hover { + background: #45a049; +} +</style> ``` ## Usage Example @@ -513,6 +436,7 @@ onUnmounted(() => { - Supports both iOS and Android - Uses back camera by default - Handles device rotation +- Provides native UI for scanning ### Web - Uses MediaDevices API @@ -520,6 +444,7 @@ onUnmounted(() => { - Handles browser compatibility - Manages memory and resources - Provides fallback UI +- Uses vue-qrcode-reader for web scanning ## Testing @@ -528,41 +453,74 @@ onUnmounted(() => { - Test platform detection - Test error handling - Test cleanup procedures +- Test permission flows 2. **Integration Tests** -- Test permission flows - Test camera access - Test QR code detection - Test cross-platform behavior +- Test UI components +- Test error scenarios 3. **E2E Tests** -- Test full scanning workflow -- Test UI feedback -- Test error scenarios -- Test platform differences - -## Common Issues and Solutions - -1. **Permission Handling** -- Always check permissions first -- Provide clear user feedback -- Handle denial gracefully -- Implement retry logic - -2. **Resource Management** -- Clean up after scanning -- Handle component unmounting -- Release camera resources -- Clear event listeners - -3. **Error Handling** -- Log errors appropriately -- Provide user feedback -- Implement fallbacks -- Handle edge cases - -4. **Performance** -- Optimize camera preview -- Handle memory usage -- Manage battery impact -- Consider device capabilities +- Test complete scanning flow +- Test permission handling +- Test cross-platform compatibility +- Test error recovery +- Test cleanup procedures + +## Best Practices + +1. **Error Handling** +- Always handle permission errors gracefully +- Provide clear error messages to users +- Implement proper cleanup on errors +- Log errors for debugging + +2. **Performance** +- Clean up resources when not in use +- Handle device rotation properly +- Optimize camera usage +- Manage memory efficiently + +3. **Security** +- Request minimum required permissions +- Handle sensitive data securely +- Validate scanned data +- Implement proper cleanup + +4. **User Experience** +- Provide clear feedback +- Handle edge cases gracefully +- Support both platforms seamlessly +- Implement proper loading states + +## Troubleshooting + +1. **Common Issues** +- Camera permissions denied +- Device not supported +- Scanner not working +- Memory leaks +- UI glitches + +2. **Solutions** +- Check permissions +- Verify device support +- Debug scanner implementation +- Monitor memory usage +- Test UI components + +## Maintenance + +1. **Regular Updates** +- Keep dependencies updated +- Monitor platform changes +- Update documentation +- Review security patches + +2. **Performance Monitoring** +- Track memory usage +- Monitor camera performance +- Check error rates +- Analyze user feedback diff --git a/android/.gradle/file-system.probe b/android/.gradle/file-system.probe index 44390a335b6402f9ddb8b68a72a8e49b763285fe..21f85321bbba8c0d15fb09c1ad15248d07005fc8 100644 GIT binary patch literal 8 PcmZQzV4M~+FMc@y2de^@ literal 8 PcmZQzV4Nn^HRTNe2NnXn diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 70ac8410..991dd37e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,4 +41,6 @@ <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> + <uses-permission android:name="android.permission.CAMERA" /> + <uses-feature android:name="android.hardware.camera" android:required="true" /> </manifest> diff --git a/package.json b/package.json index 15ac17ea..a43aa208 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,12 @@ "clean:electron": "rimraf dist-electron", "build:pywebview": "vite build --config vite.config.pywebview.mts", "build:electron": "npm run clean:electron && vite build --config vite.config.electron.mts && node scripts/build-electron.js", - "build:capacitor": "vite build --config vite.config.capacitor.mts", + "build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts", "build:web": "vite build --config vite.config.web.mts", "electron:dev": "npm run build && electron dist-electron", "electron:start": "electron dist-electron", - "build:android": "rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android", + "clean:android": "adb uninstall app.timesafari.app || true", + "build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android", "electron:build-linux": "npm run build:electron && electron-builder --linux AppImage", "electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb", "electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage", diff --git a/src/components/QRScanner/QRScannerDialog.vue b/src/components/QRScanner/QRScannerDialog.vue index 4e102aaf..9f2efebe 100644 --- a/src/components/QRScanner/QRScannerDialog.vue +++ b/src/components/QRScanner/QRScannerDialog.vue @@ -80,15 +80,21 @@ import { QRScannerOptions } from "@/services/QRScanner/types"; import { logger } from "@/utils/logger"; import { Capacitor } from "@capacitor/core"; +interface ScanProps { + onScan: (result: string) => void; + onError?: (error: Error) => void; + options?: QRScannerOptions; +} + @Component({ components: { QrcodeStream, }, }) export default class QRScannerDialog extends Vue { - @Prop({ type: Function, required: true }) onScan!: (result: string) => void; - @Prop({ type: Function }) onError?: (error: Error) => void; - @Prop({ type: Object }) options?: QRScannerOptions; + @Prop({ type: Function, required: true }) onScan!: ScanProps['onScan']; + @Prop({ type: Function }) onError?: ScanProps['onError']; + @Prop({ type: Object }) options?: ScanProps['options']; visible = true; error: string | null = null; @@ -126,11 +132,12 @@ export default class QRScannerDialog extends Vue { await promise; this.error = null; } catch (error) { - this.error = error instanceof Error ? error.message : String(error); + const wrappedError = error instanceof Error ? error : new Error(String(error)); + this.error = wrappedError.message; if (this.onError) { - this.onError(error instanceof Error ? error : new Error(String(error))); + this.onError(wrappedError); } - logger.error("Error initializing QR scanner:", error); + logger.error("Error initializing QR scanner:", wrappedError); } } @@ -139,11 +146,12 @@ export default class QRScannerDialog extends Vue { this.onScan(result); this.close(); } catch (error) { - this.error = error instanceof Error ? error.message : String(error); + const wrappedError = error instanceof Error ? error : new Error(String(error)); + this.error = wrappedError.message; if (this.onError) { - this.onError(error instanceof Error ? error : new Error(String(error))); + this.onError(wrappedError); } - logger.error("Error handling QR scan result:", error); + logger.error("Error handling QR scan result:", wrappedError); } } diff --git a/src/services/QRScanner/CapacitorQRScanner.ts b/src/services/QRScanner/CapacitorQRScanner.ts index 1fcb644b..4edbd1de 100644 --- a/src/services/QRScanner/CapacitorQRScanner.ts +++ b/src/services/QRScanner/CapacitorQRScanner.ts @@ -10,13 +10,16 @@ import { logger } from "@/utils/logger"; export class CapacitorQRScanner implements QRScannerService { private scanListener: ScanListener | null = null; private isScanning = false; + private listenerHandles: Array<() => Promise<void>> = []; async checkPermissions(): Promise<boolean> { try { const { camera } = await BarcodeScanner.checkPermissions(); return camera === "granted"; } catch (error) { - logger.error("Error checking camera permissions:", error); + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error checking camera permissions:", wrappedError); return false; } } @@ -32,7 +35,9 @@ export class CapacitorQRScanner implements QRScannerService { const { camera } = await BarcodeScanner.requestPermissions(); return camera === "granted"; } catch (error) { - logger.error("Error requesting camera permissions:", error); + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error requesting camera permissions:", wrappedError); return false; } } @@ -42,7 +47,9 @@ export class CapacitorQRScanner implements QRScannerService { const { supported } = await BarcodeScanner.isSupported(); return supported; } catch (error) { - logger.error("Error checking scanner support:", error); + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error checking scanner support:", wrappedError); return false; } } @@ -79,17 +86,23 @@ export class CapacitorQRScanner implements QRScannerService { }; logger.log("Scanner options:", scanOptions); - const result = await BarcodeScanner.scan(scanOptions); - logger.log("Scan result:", result); - - if (result.barcodes.length > 0) { - this.scanListener?.onScan(result.barcodes[0].rawValue); - } + + // Add listener for barcode scans + const handle = await BarcodeScanner.addListener('barcodeScanned', (result) => { + if (this.scanListener) { + this.scanListener.onScan(result.barcode.rawValue); + } + }); + this.listenerHandles.push(handle.remove); + + // Start continuous scanning + await BarcodeScanner.startScan(scanOptions); } catch (error) { - logger.error("Error during QR scan:", error); - this.scanListener?.onError?.(error as Error); - } finally { - this.isScanning = false; + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error during QR scan:", wrappedError); + this.scanListener?.onError?.(wrappedError); + throw wrappedError; } } @@ -100,10 +113,14 @@ export class CapacitorQRScanner implements QRScannerService { try { await BarcodeScanner.stopScan(); - this.isScanning = false; } catch (error) { - logger.error("Error stopping QR scan:", error); - this.scanListener?.onError?.(error as Error); + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error stopping QR scan:", wrappedError); + this.scanListener?.onError?.(wrappedError); + throw wrappedError; + } finally { + this.isScanning = false; } } @@ -112,7 +129,19 @@ export class CapacitorQRScanner implements QRScannerService { } async cleanup(): Promise<void> { - await this.stopScan(); - this.scanListener = null; + try { + await this.stopScan(); + for (const handle of this.listenerHandles) { + await handle(); + } + } catch (error) { + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error during cleanup:", wrappedError); + throw wrappedError; + } finally { + this.listenerHandles = []; + this.scanListener = null; + } } } diff --git a/src/services/QRScanner/QRScannerFactory.ts b/src/services/QRScanner/QRScannerFactory.ts index fc8e9b49..216eda91 100644 --- a/src/services/QRScanner/QRScannerFactory.ts +++ b/src/services/QRScanner/QRScannerFactory.ts @@ -11,8 +11,15 @@ export class QRScannerFactory { private static instance: QRScannerService | null = null; private static isNativePlatform(): boolean { + // Debug logging for build flags + logger.log("Build flags:", { + IS_MOBILE: typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : 'undefined', + USE_QR_READER: typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : 'undefined', + VITE_PLATFORM: process.env.VITE_PLATFORM, + }); + const capacitorNative = Capacitor.isNativePlatform(); - const isMobile = __IS_MOBILE__; + const isMobile = typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : capacitorNative; const platform = Capacitor.getPlatform(); logger.log("Platform detection:", { @@ -22,12 +29,16 @@ export class QRScannerFactory { userAgent: navigator.userAgent, }); - // Force native scanner on Android/iOS + // Always use native scanner on Android/iOS if (platform === "android" || platform === "ios") { + logger.log("Using native scanner due to platform:", platform); return true; } - return capacitorNative || isMobile; + // For other platforms, use native if available + const useNative = capacitorNative || isMobile; + logger.log("Platform decision:", { useNative, reason: useNative ? "capacitorNative/isMobile" : "web" }); + return useNative; } /** @@ -40,19 +51,24 @@ export class QRScannerFactory { `Creating QR scanner for platform: ${isNative ? "native" : "web"}`, ); - if (isNative) { - logger.log("Using native MLKit scanner"); - this.instance = new CapacitorQRScanner(); - } else if (__USE_QR_READER__) { - logger.log("Using web QR scanner"); - this.instance = new WebDialogQRScanner(); - } else { - throw new Error( - "No QR scanner implementation available for this platform", - ); + try { + if (isNative) { + logger.log("Using native MLKit scanner"); + this.instance = new CapacitorQRScanner(); + } else if (typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : !isNative) { + logger.log("Using web QR scanner"); + this.instance = new WebDialogQRScanner(); + } else { + throw new Error( + "No QR scanner implementation available for this platform", + ); + } + } catch (error) { + logger.error("Error creating QR scanner:", error); + throw error; } } - return this.instance!; // We know it's not null here + return this.instance!; } /** @@ -60,8 +76,13 @@ export class QRScannerFactory { */ static async cleanup(): Promise<void> { if (this.instance) { - await this.instance.cleanup(); - this.instance = null; + try { + await this.instance.cleanup(); + } catch (error) { + logger.error("Error cleaning up QR scanner:", error); + } finally { + this.instance = null; + } } } } diff --git a/src/services/QRScanner/WebDialogQRScanner.ts b/src/services/QRScanner/WebDialogQRScanner.ts index 0ed57f86..bf592fab 100644 --- a/src/services/QRScanner/WebDialogQRScanner.ts +++ b/src/services/QRScanner/WebDialogQRScanner.ts @@ -8,6 +8,7 @@ export class WebDialogQRScanner implements QRScannerService { private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null; private scanListener: ScanListener | null = null; private isScanning = false; + private container: HTMLElement | null = null; constructor(private options?: QRScannerOptions) {} @@ -18,7 +19,9 @@ export class WebDialogQRScanner implements QRScannerService { }); return permissions.state === "granted"; } catch (error) { - logger.error("Error checking camera permissions:", error); + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error checking camera permissions:", wrappedError); return false; } } @@ -29,7 +32,9 @@ export class WebDialogQRScanner implements QRScannerService { stream.getTracks().forEach((track) => track.stop()); return true; } catch (error) { - logger.error("Error requesting camera permissions:", error); + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error requesting camera permissions:", wrappedError); return false; } } @@ -47,8 +52,8 @@ export class WebDialogQRScanner implements QRScannerService { this.isScanning = true; // Create and mount dialog component - const container = document.createElement("div"); - document.body.appendChild(container); + this.container = document.createElement("div"); + document.body.appendChild(this.container); this.dialogInstance = createApp(QRScannerDialog, { onScan: (result: string) => { @@ -64,16 +69,18 @@ export class WebDialogQRScanner implements QRScannerService { options: this.options, }); - this.dialogComponent = this.dialogInstance.mount(container).$refs + this.dialogComponent = this.dialogInstance.mount(this.container).$refs .dialog as InstanceType<typeof QRScannerDialog>; } catch (error) { this.isScanning = false; + const wrappedError = + error instanceof Error ? error : new Error(String(error)); if (this.scanListener?.onError) { - this.scanListener.onError( - error instanceof Error ? error : new Error(String(error)), - ); + this.scanListener.onError(wrappedError); } - logger.error("Error starting scan:", error); + logger.error("Error starting scan:", wrappedError); + this.cleanupContainer(); + throw wrappedError; } } @@ -89,9 +96,14 @@ export class WebDialogQRScanner implements QRScannerService { if (this.dialogInstance) { this.dialogInstance.unmount(); } - this.isScanning = false; } catch (error) { - logger.error("Error stopping scan:", error); + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error stopping scan:", wrappedError); + throw wrappedError; + } finally { + this.isScanning = false; + this.cleanupContainer(); } } @@ -99,10 +111,26 @@ export class WebDialogQRScanner implements QRScannerService { this.scanListener = listener; } + private cleanupContainer(): void { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.container = null; + } + async cleanup(): Promise<void> { - await this.stopScan(); - this.dialogComponent = null; - this.dialogInstance = null; - this.scanListener = null; + try { + await this.stopScan(); + } catch (error) { + const wrappedError = + error instanceof Error ? error : new Error(String(error)); + logger.error("Error during cleanup:", wrappedError); + throw wrappedError; + } finally { + this.dialogComponent = null; + this.dialogInstance = null; + this.scanListener = null; + this.cleanupContainer(); + } } } diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index f315e658..01720407 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -27,7 +27,7 @@ <br /> <span class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md" - @click="() => $refs.userNameDialog.open((name) => (givenName = name))" + @click="openUserNameDialog" > click here to set it for them. </span> @@ -77,8 +77,19 @@ <div class="text-center"> <h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1> - <qrcode-stream @detect="onScanDetect" @error="onScanError" /> - <span> + <div v-if="isScanning" class="relative aspect-square"> + <div class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none"></div> + </div> + <div v-else> + <button + class="bg-blue-500 text-white px-4 py-2 rounded-md mt-4" + @click="startScanning" + > + Start Scanning + </button> + </div> + <span v-if="error" class="text-red-500 block mt-2">{{ error }}</span> + <span v-else class="block mt-2"> If you do not see a scanning camera window here, check your camera permissions. </span> @@ -90,7 +101,6 @@ import { AxiosError } from "axios"; import QRCodeVue3 from "qr-code-generator-vue3"; import { Component, Vue } from "vue-facing-decorator"; -import { QrcodeStream } from "vue-qrcode-reader"; import { useClipboard } from "@vueuse/core"; import QuickNav from "../components/QuickNav.vue"; @@ -110,9 +120,10 @@ import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; import { retrieveAccountMetadata } from "../libs/util"; import { Router } from "vue-router"; import { logger } from "../utils/logger"; +import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory"; + @Component({ components: { - QrcodeStream, QRCodeVue3, QuickNav, UserNameDialog, @@ -128,6 +139,8 @@ export default class ContactQRScanShow extends Vue { hideRegisterPromptOnNewContact = false; isRegistered = false; qrValue = ""; + isScanning = false; + error: string | null = null; ETHR_DID_PREFIX = ETHR_DID_PREFIX; @@ -150,12 +163,55 @@ export default class ContactQRScanShow extends Vue { account, !!settings.isRegistered, name, - settings.profileImageUrl, + settings.profileImageUrl || "", false, ); } } + async startScanning() { + try { + this.error = null; + this.isScanning = true; + + const scanner = QRScannerFactory.getInstance(); + + // Check permissions first + if (!(await scanner.checkPermissions())) { + const granted = await scanner.requestPermissions(); + if (!granted) { + this.error = "Camera permission denied"; + this.isScanning = false; + return; + } + } + + // Add scan listener + scanner.addListener({ + onScan: this.onScanDetect, + onError: this.onScanError + }); + + // Start scanning + await scanner.startScan(); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + this.isScanning = false; + logger.error("Error starting scan:", error); + } + } + + async stopScanning() { + try { + const scanner = QRScannerFactory.getInstance(); + await scanner.stopScan(); + } catch (error) { + logger.error("Error stopping scan:", error); + } finally { + this.isScanning = false; + } + } + danger(message: string, title: string = "Error", timeout = 5000) { this.$notify( { @@ -169,49 +225,37 @@ export default class ContactQRScanShow extends Vue { } /** - * - * @param content is the result of a QR scan, an array with one item with a rawValue property + * Handle QR code scan result */ - // Unfortunately, there are not typescript definitions for the qrcode-stream component yet. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async onScanDetect(content: any) { - const url = content[0]?.rawValue; - if (url) { + async onScanDetect(result: string) { + try { let newContact: Contact; - try { - const jwt = getContactJwtFromJwtUrl(url); - if (!jwt) { - this.$notify( - { - group: "alert", - type: "danger", - title: "No Contact Info", - text: "The contact info could not be parsed.", - }, - 3000, - ); - return; - } - const { payload } = decodeEndorserJwt(jwt); - newContact = { - did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49 - name: payload.own.name, - nextPubKeyHashB64: payload.own.nextPublicEncKeyHash, - profileImageUrl: payload.own.profileImageUrl, - publicKeyBase64: payload.own.publicEncKey, - registered: payload.own.registered, - }; - if (!newContact.did) { - this.danger("There is no DID.", "Incomplete Contact"); - return; - } - if (!isDid(newContact.did)) { - this.danger("The DID must begin with 'did:'", "Invalid DID"); - return; - } - } catch (e) { - logger.error("Error parsing QR info:", e); - this.danger("Could not parse the QR info.", "Read Error"); + const jwt = getContactJwtFromJwtUrl(result); + if (!jwt) { + this.$notify( + { + group: "alert", + type: "danger", + title: "No Contact Info", + text: "The contact info could not be parsed.", + }, + 3000, + ); + return; + } + const { payload } = decodeEndorserJwt(jwt); + newContact = { + did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49 + name: payload.own.name, + nextPubKeyHashB64: payload.own.nextPublicEncKeyHash, + profileImageUrl: payload.own.profileImageUrl, + }; + if (!newContact.did) { + this.danger("There is no DID.", "Incomplete Contact"); + return; + } + if (!isDid(newContact.did)) { + this.danger("The DID must begin with 'did:'", "Invalid DID"); return; } @@ -247,7 +291,7 @@ export default class ContactQRScanShow extends Vue { type: "confirm", title: "Register", text: "Do you want to register them?", - onCancel: async (stopAsking: boolean) => { + onCancel: async (stopAsking?: boolean) => { if (stopAsking) { await db.settings.update(MASTER_SETTINGS_KEY, { hideRegisterPromptOnNewContact: stopAsking, @@ -255,7 +299,7 @@ export default class ContactQRScanShow extends Vue { this.hideRegisterPromptOnNewContact = stopAsking; } }, - onNo: async (stopAsking: boolean) => { + onNo: async (stopAsking?: boolean) => { if (stopAsking) { await db.settings.update(MASTER_SETTINGS_KEY, { hideRegisterPromptOnNewContact: stopAsking, @@ -285,16 +329,12 @@ export default class ContactQRScanShow extends Vue { 5000, ); } - } else { - this.$notify( - { - group: "alert", - type: "danger", - title: "Invalid Contact QR Code", - text: "No QR code detected with contact information.", - }, - 5000, - ); + + // Stop scanning after successful scan + await this.stopScanning(); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + logger.error("Error processing scan result:", error); } } @@ -364,8 +404,8 @@ export default class ContactQRScanShow extends Vue { let userMessage = "There was an error."; const serverError = error as AxiosError; if (serverError) { - if (serverError.response?.data?.error?.message) { - userMessage = serverError.response.data.error.message; + if (serverError.response?.data && typeof serverError.response.data === 'object' && 'message' in serverError.response.data) { + userMessage = (serverError.response.data as {message: string}).message; } else if (serverError.message) { userMessage = serverError.message; // Info for the user } else { @@ -387,18 +427,9 @@ export default class ContactQRScanShow extends Vue { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onScanError(error: any) { - logger.error("Scan was invalid:", error); - this.$notify( - { - group: "alert", - type: "danger", - title: "Invalid Scan", - text: "The scan was invalid.", - }, - 5000, - ); + onScanError(error: Error) { + this.error = error.message; + logger.error("Scan error:", error); } onCopyUrlToClipboard() { @@ -435,5 +466,22 @@ export default class ContactQRScanShow extends Vue { ); }); } + + openUserNameDialog() { + (this.$refs.userNameDialog as any).open((name: string) => { + this.givenName = name; + }); + } + + beforeDestroy() { + // Clean up scanner when component is destroyed + QRScannerFactory.cleanup(); + } } </script> + +<style scoped> +.aspect-square { + aspect-ratio: 1 / 1; +} +</style> diff --git a/vite.config.common.mts b/vite.config.common.mts index 1e288f5f..483dc4d4 100644 --- a/vite.config.common.mts +++ b/vite.config.common.mts @@ -44,6 +44,8 @@ export async function createBuildConfig(mode: string) { 'process.env.VITE_PLATFORM': JSON.stringify(mode), 'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)), __dirname: isElectron ? JSON.stringify(process.cwd()) : '""', + __IS_MOBILE__: JSON.stringify(isCapacitor), + __USE_QR_READER__: JSON.stringify(!isCapacitor), }, resolve: { alias: {