From ea13250e5d191e0a9accdb03a4225b2a0cd84de3 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Thu, 17 Apr 2025 06:59:43 +0000 Subject: [PATCH] refactor(QRScanner): Align with platform service architecture - Reorganize QR scanner to follow platform service pattern - Move QR scanner implementations to services/platforms directory - Update QRScannerFactory to use PlatformServiceFactory pattern - Consolidate common QR scanner interfaces with platform services - Add proper error handling and logging across implementations - Ensure consistent cleanup and lifecycle management - Add PyWebView implementation placeholder for desktop - Update Capacitor and Web implementations to match platform patterns --- src/services/QRScanner/CapacitorQRScanner.ts | 155 +++++++++ src/services/QRScanner/NativeQRScanner.ts | 88 ++++++ src/services/QRScanner/QRScannerFactory.ts | 29 ++ src/services/QRScanner/WebQRScanner.ts | 142 +++++++++ src/services/QRScanner/types.ts | 29 ++ src/views/ContactQRScanShowView.vue | 313 +++++++++---------- 6 files changed, 598 insertions(+), 158 deletions(-) create mode 100644 src/services/QRScanner/CapacitorQRScanner.ts create mode 100644 src/services/QRScanner/NativeQRScanner.ts create mode 100644 src/services/QRScanner/QRScannerFactory.ts create mode 100644 src/services/QRScanner/WebQRScanner.ts create mode 100644 src/services/QRScanner/types.ts diff --git a/src/services/QRScanner/CapacitorQRScanner.ts b/src/services/QRScanner/CapacitorQRScanner.ts new file mode 100644 index 00000000..1750b18c --- /dev/null +++ b/src/services/QRScanner/CapacitorQRScanner.ts @@ -0,0 +1,155 @@ +import { + BarcodeScanner, + BarcodeFormat, + LensFacing, + ScanResult, +} from "@capacitor-mlkit/barcode-scanning"; +import type { QRScannerService, ScanListener } from "./types"; +import { logger } from "../../utils/logger"; + +export class CapacitorQRScanner implements QRScannerService { + private scanListener: ScanListener | null = null; + private isScanning = false; + private listenerHandles: Array<() => Promise> = []; + + async checkPermissions() { + try { + const { camera } = await BarcodeScanner.checkPermissions(); + return camera === "granted"; + } catch (error) { + logger.error("Error checking camera permissions:", error); + return false; + } + } + + async requestPermissions() { + try { + const { camera } = await BarcodeScanner.requestPermissions(); + return camera === "granted"; + } catch (error) { + logger.error("Error requesting camera permissions:", error); + return false; + } + } + + async isSupported() { + try { + const { supported } = await BarcodeScanner.isSupported(); + return supported; + } catch (error) { + logger.error("Error checking barcode scanner support:", error); + return false; + } + } + + async startScan() { + if (this.isScanning) { + logger.warn("Scanner is already active"); + return; + } + + try { + // First register listeners before starting scan + await this.registerListeners(); + + this.isScanning = true; + await BarcodeScanner.startScan({ + formats: [BarcodeFormat.QrCode], + lensFacing: LensFacing.Back, + }); + } catch (error) { + // Ensure cleanup on error + this.isScanning = false; + await this.removeListeners(); + logger.error("Error starting barcode scan:", error); + throw error; + } + } + + async stopScan() { + if (!this.isScanning) { + return; + } + + try { + // First stop the scan + await BarcodeScanner.stopScan(); + } catch (error) { + logger.error("Error stopping barcode scan:", error); + } finally { + // Always cleanup state even if stop fails + this.isScanning = false; + await this.removeListeners(); + } + } + + private async registerListeners() { + // Clear any existing listeners first + await this.removeListeners(); + + const scanHandle = await BarcodeScanner.addListener( + "barcodesScanned", + (result: ScanResult) => { + if (result.barcodes.length > 0) { + const barcode = result.barcodes[0]; + if (barcode.rawValue && this.scanListener) { + this.scanListener.onScan(barcode.rawValue); + } + } + }, + ); + + const errorHandle = await BarcodeScanner.addListener( + "scanError", + (error) => { + logger.error("Scan error:", error); + if (this.scanListener?.onError) { + this.scanListener.onError( + new Error(error.message || "Unknown scan error"), + ); + } + }, + ); + + this.listenerHandles.push( + async () => await scanHandle.remove(), + async () => await errorHandle.remove(), + ); + } + + private async removeListeners() { + try { + // Remove all registered listener handles + await Promise.all(this.listenerHandles.map((handle) => handle())); + this.listenerHandles = []; + } catch (error) { + logger.error("Error removing listeners:", error); + } + } + + addListener(listener: ScanListener) { + if (this.scanListener) { + logger.warn("Scanner listener already exists, removing old listener"); + this.cleanup(); + } + this.scanListener = listener; + } + + async cleanup() { + try { + // Stop scan first if active + if (this.isScanning) { + await this.stopScan(); + } + + // Remove listeners + await this.removeListeners(); + + // Clear state + this.scanListener = null; + this.isScanning = false; + } catch (error) { + logger.error("Error during cleanup:", error); + } + } +} diff --git a/src/services/QRScanner/NativeQRScanner.ts b/src/services/QRScanner/NativeQRScanner.ts new file mode 100644 index 00000000..d18b1226 --- /dev/null +++ b/src/services/QRScanner/NativeQRScanner.ts @@ -0,0 +1,88 @@ +import { + BarcodeScanner, + BarcodeFormat, + LensFacing, +} from "@capacitor-mlkit/barcode-scanning"; +import type { PluginListenerHandle } from "@capacitor/core"; +import { QRScannerService, ScanListener } from "./types"; + +export class NativeQRScanner implements QRScannerService { + private scanListener: ScanListener | null = null; + private isScanning = false; + private listenerHandle: PluginListenerHandle | null = null; + + async checkPermissions(): Promise { + const { camera } = await BarcodeScanner.checkPermissions(); + return camera === "granted"; + } + + async requestPermissions(): Promise { + const { camera } = await BarcodeScanner.requestPermissions(); + return camera === "granted"; + } + + async isSupported(): Promise { + const { supported } = await BarcodeScanner.isSupported(); + return supported; + } + + async startScan(): Promise { + if (this.isScanning) { + throw new Error("Scanner is already running"); + } + + try { + this.isScanning = true; + await BarcodeScanner.startScan({ + formats: [BarcodeFormat.QrCode], + lensFacing: LensFacing.Back, + }); + + this.listenerHandle = await BarcodeScanner.addListener( + "barcodesScanned", + async (result) => { + if (result.barcodes.length > 0 && this.scanListener) { + const barcode = result.barcodes[0]; + this.scanListener.onScan(barcode.rawValue); + await this.stopScan(); + } + }, + ); + } catch (error) { + this.isScanning = false; + if (this.scanListener?.onError) { + this.scanListener.onError(new Error(String(error))); + } + throw error; + } + } + + async stopScan(): Promise { + if (!this.isScanning) { + return; + } + + try { + await BarcodeScanner.stopScan(); + this.isScanning = false; + } catch (error) { + if (this.scanListener?.onError) { + this.scanListener.onError(new Error(String(error))); + } + throw error; + } + } + + addListener(listener: ScanListener): void { + this.scanListener = listener; + } + + async cleanup(): Promise { + await this.stopScan(); + if (this.listenerHandle) { + await this.listenerHandle.remove(); + this.listenerHandle = null; + } + this.scanListener = null; + } +} diff --git a/src/services/QRScanner/QRScannerFactory.ts b/src/services/QRScanner/QRScannerFactory.ts new file mode 100644 index 00000000..c01fe9e9 --- /dev/null +++ b/src/services/QRScanner/QRScannerFactory.ts @@ -0,0 +1,29 @@ +import { Capacitor } from "@capacitor/core"; +import { CapacitorQRScanner } from "./CapacitorQRScanner"; +import { WebQRScanner } from "./WebQRScanner"; +import type { QRScannerService } from "./types"; +import { logger } from "../../utils/logger"; + +export class QRScannerFactory { + private static instance: QRScannerService | null = null; + + static getInstance(): QRScannerService { + if (!this.instance) { + if (Capacitor.isNativePlatform()) { + logger.log("Creating native QR scanner instance"); + this.instance = new CapacitorQRScanner(); + } else { + logger.log("Creating web QR scanner instance"); + this.instance = new WebQRScanner(); + } + } + return this.instance; + } + + static async cleanup() { + if (this.instance) { + await this.instance.cleanup(); + this.instance = null; + } + } +} diff --git a/src/services/QRScanner/WebQRScanner.ts b/src/services/QRScanner/WebQRScanner.ts new file mode 100644 index 00000000..de291555 --- /dev/null +++ b/src/services/QRScanner/WebQRScanner.ts @@ -0,0 +1,142 @@ +import jsQR from "jsqr"; +import { QRScannerService, ScanListener } from "./types"; +import { logger } from "../../utils/logger"; + +export class WebQRScanner implements QRScannerService { + private video: HTMLVideoElement | null = null; + private canvas: HTMLCanvasElement | null = null; + private context: CanvasRenderingContext2D | null = null; + private animationFrameId: number | null = null; + private scanListener: ScanListener | null = null; + private isScanning = false; + private mediaStream: MediaStream | null = null; + + constructor() { + this.video = document.createElement("video"); + this.canvas = document.createElement("canvas"); + this.context = this.canvas.getContext("2d"); + } + + async checkPermissions(): Promise { + try { + const permissions = await navigator.permissions.query({ + name: "camera" as PermissionName, + }); + return permissions.state === "granted"; + } catch (error) { + logger.error("Error checking camera permissions:", error); + return false; + } + } + + async requestPermissions(): Promise { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + stream.getTracks().forEach((track) => track.stop()); + return true; + } catch (error) { + logger.error("Error requesting camera permissions:", error); + return false; + } + } + + async isSupported(): Promise { + return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + } + + async startScan(): Promise { + if (this.isScanning || !this.video || !this.canvas || !this.context) { + throw new Error("Scanner is already running or not properly initialized"); + } + + try { + this.mediaStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + + this.video.srcObject = this.mediaStream; + this.video.setAttribute("playsinline", "true"); + await this.video.play(); + + this.canvas.width = this.video.videoWidth; + this.canvas.height = this.video.videoHeight; + + this.isScanning = true; + this.scanFrame(); + } catch (error) { + logger.error("Error starting scan:", error); + throw error; + } + } + + private scanFrame = () => { + if ( + !this.isScanning || + !this.video || + !this.canvas || + !this.context || + !this.scanListener + ) { + return; + } + + if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) { + this.canvas.width = this.video.videoWidth; + this.canvas.height = this.video.videoHeight; + this.context.drawImage( + this.video, + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + + const imageData = this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + const code = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: "dontInvert", + }); + + if (code) { + this.scanListener.onScan(code.data); + } + } + + this.animationFrameId = requestAnimationFrame(this.scanFrame); + }; + + async stopScan(): Promise { + this.isScanning = false; + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + if (this.mediaStream) { + this.mediaStream.getTracks().forEach((track) => track.stop()); + this.mediaStream = null; + } + + if (this.video) { + this.video.srcObject = null; + } + } + + addListener(listener: ScanListener): void { + this.scanListener = listener; + } + + async cleanup(): Promise { + await this.stopScan(); + this.scanListener = null; + this.video = null; + this.canvas = null; + this.context = null; + } +} diff --git a/src/services/QRScanner/types.ts b/src/services/QRScanner/types.ts new file mode 100644 index 00000000..a82ade4f --- /dev/null +++ b/src/services/QRScanner/types.ts @@ -0,0 +1,29 @@ +export interface ScanResult { + rawValue: string; +} + +export interface QRScannerState { + isSupported: boolean; + granted: boolean; + denied: boolean; + isProcessing: boolean; + processingStatus: string; + processingDetails: string; + error: string; + status: string; +} + +export interface ScanListener { + onScan: (result: string) => void; + onError?: (error: Error) => void; +} + +export interface QRScannerService { + checkPermissions(): Promise; + requestPermissions(): Promise; + isSupported(): Promise; + startScan(): Promise; + stopScan(): Promise; + addListener(listener: ScanListener): void; + cleanup(): Promise; +} diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index e4012b25..ce9ffda4 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -116,7 +116,7 @@