Browse Source

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
qrcode-capacitor
Matthew Raymer 2 months ago
parent
commit
ea13250e5d
  1. 155
      src/services/QRScanner/CapacitorQRScanner.ts
  2. 88
      src/services/QRScanner/NativeQRScanner.ts
  3. 29
      src/services/QRScanner/QRScannerFactory.ts
  4. 142
      src/services/QRScanner/WebQRScanner.ts
  5. 29
      src/services/QRScanner/types.ts
  6. 313
      src/views/ContactQRScanShowView.vue

155
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<void>> = [];
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);
}
}
}

88
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<boolean> {
const { camera } = await BarcodeScanner.checkPermissions();
return camera === "granted";
}
async requestPermissions(): Promise<boolean> {
const { camera } = await BarcodeScanner.requestPermissions();
return camera === "granted";
}
async isSupported(): Promise<boolean> {
const { supported } = await BarcodeScanner.isSupported();
return supported;
}
async startScan(): Promise<void> {
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<void> {
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<void> {
await this.stopScan();
if (this.listenerHandle) {
await this.listenerHandle.remove();
this.listenerHandle = null;
}
this.scanListener = null;
}
}

29
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;
}
}
}

142
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<boolean> {
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<boolean> {
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<boolean> {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
async startScan(): Promise<void> {
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<void> {
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<void> {
await this.stopScan();
this.scanListener = null;
this.video = null;
this.canvas = null;
this.context = null;
}
}

29
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<boolean>;
requestPermissions(): Promise<boolean>;
isSupported(): Promise<boolean>;
startScan(): Promise<void>;
stopScan(): Promise<void>;
addListener(listener: ScanListener): void;
cleanup(): Promise<void>;
}

313
src/views/ContactQRScanShowView.vue

@ -116,7 +116,7 @@
<script lang="ts">
import { AxiosError } from "axios";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue, Ref } from "vue-facing-decorator";
import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { PlatformService } from "../services/PlatformService";
@ -205,19 +205,6 @@ interface AppState {
scannerState: ScannerState;
}
interface Barcode {
rawValue: string;
bytes?: number[];
}
interface MLKitScanResult {
barcodes: Barcode[];
}
interface WebQRResult {
rawValue: string;
}
@Component({
components: {
QRCodeVue3,
@ -229,11 +216,6 @@ interface WebQRResult {
export default class ContactQRScanShowView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
@Ref()
readonly userNameDialog!: {
open: (callback: (name: string) => void) => void;
};
declare $refs: {
userNameDialog: {
open: (callback: (name: string) => void) => void;
@ -441,7 +423,42 @@ export default class ContactQRScanShowView extends Vue {
this.lastCameraState = state;
}
private async openMobileCamera() {
beforeDestroy() {
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
const cleanupListeners = async () => {
if (this.appStateListener) {
try {
await this.appStateListener.remove();
logger.log("App state change listener removed successfully");
} catch (error) {
logger.error("Error removing app state change listener:", error);
}
}
try {
await App.removeAllListeners();
logger.log("All app listeners removed successfully");
} catch (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);
});
}
async openMobileCamera() {
try {
this.state.isProcessing = true;
this.state.processingStatus = "Starting camera...";
@ -449,7 +466,7 @@ export default class ContactQRScanShowView extends Vue {
// Check current permission status
const status = await BarcodeScanner.checkPermissions();
logger.log("Camera permission status:", status);
logger.log("Camera permission status:", JSON.stringify(status, null, 2));
if (status.camera !== "granted") {
// Request permission if not granted
@ -458,32 +475,36 @@ export default class ContactQRScanShowView extends Vue {
if (permissionStatus.camera !== "granted") {
throw new Error("Camera permission not granted");
}
logger.log("Camera permission granted:", permissionStatus);
logger.log(
"Camera permission granted:",
JSON.stringify(permissionStatus, null, 2),
);
}
// Remove any existing listener first
await this.cleanupScanListener();
try {
if (this.scanListener) {
logger.log("Removing existing barcode listener");
await this.scanListener.remove();
this.scanListener = null;
}
} catch (error) {
logger.error("Error removing existing listener:", error);
// Continue with setup even if removal fails
}
// Set up the listener before starting the scan
logger.log("Setting up new barcode listener");
this.scanListener = await BarcodeScanner.addListener(
"barcodesScanned",
async (result: ScanResult) => {
try {
logger.log("Barcode scan result received:", result);
if (result.barcodes && result.barcodes.length > 0) {
const barcode = result.barcodes[0];
if (!barcode.rawValue) {
logger.warn("Received empty barcode value");
return;
}
this.state.processingDetails = `Processing QR code: ${barcode.rawValue}`;
await this.handleScanResult(barcode.rawValue);
}
} catch (error) {
logger.error("Error processing barcode result:", error);
this.showError("Failed to process QR code");
logger.log(
"Barcode scan result received:",
JSON.stringify(result, null, 2),
);
if (result.barcodes && result.barcodes.length > 0) {
this.state.processingDetails = `Processing QR code: ${result.barcodes[0].rawValue}`;
await this.handleScanResult(result.barcodes[0].rawValue);
}
},
);
@ -506,19 +527,14 @@ export default class ContactQRScanShowView extends Vue {
);
// Cleanup on error
await this.cleanupScanListener();
}
}
private async cleanupScanListener(): Promise<void> {
try {
if (this.scanListener) {
logger.log("Removing existing barcode listener");
await this.scanListener.remove();
this.scanListener = null;
try {
if (this.scanListener) {
await this.scanListener.remove();
this.scanListener = null;
}
} catch (cleanupError) {
logger.error("Error during cleanup:", cleanupError);
}
} catch (error) {
logger.error("Error removing barcode listener:", error);
}
}
@ -527,27 +543,15 @@ export default class ContactQRScanShowView extends Vue {
this.state.isProcessing = true;
this.state.processingStatus = "Processing QR code...";
this.state.processingDetails = `Scanned value: ${rawValue}`;
logger.log("Processing scanned QR code:", rawValue);
// Stop scanning before processing
await this.stopScanning();
// Validate URL format first
if (!rawValue.startsWith("http://") && !rawValue.startsWith("https://")) {
throw new Error(
"Invalid QR code format. Please scan a valid TimeSafari contact QR code.",
);
}
// Process the scan result
await this.onScanDetect({ rawValue });
} catch (error) {
logger.error("Error handling scan result:", error);
this.showError(
error instanceof Error
? error.message
: "Failed to process scan result",
);
this.showError("Failed to process scan result");
} finally {
this.state.isProcessing = false;
this.state.processingStatus = "";
@ -590,110 +594,124 @@ export default class ContactQRScanShowView extends Vue {
}
/**
* Handle QR code scan result
* @param content scan result from barcode scanner
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
*/
async onScanDetect(content: unknown): Promise<void> {
// Extract URL from different possible formats
let url: string | null = null;
if (typeof content === "object" && content !== null) {
// Handle Capacitor MLKit scanner format
if (
"barcodes" in content &&
Array.isArray((content as MLKitScanResult).barcodes)
) {
const mlkitResult = content as MLKitScanResult;
url = mlkitResult.barcodes[0]?.rawValue;
}
// Handle web QR reader format
else if (Array.isArray(content)) {
const webResult = content as WebQRResult[];
url = webResult[0]?.rawValue;
}
// Handle direct object format
else if ("rawValue" in content) {
const directResult = content as WebQRResult;
url = directResult.rawValue;
}
// 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): Promise<void> {
// Log the received content for debugging
logger.log("Scan result received:", JSON.stringify(content, null, 2));
// Handle both array format and direct object format
let rawValue: string | undefined;
if (Array.isArray(content)) {
rawValue = content[0]?.rawValue;
logger.log("Processing array format, rawValue:", rawValue);
} else if (content?.barcodes?.[0]?.rawValue) {
rawValue = content.barcodes[0].rawValue;
logger.log("Processing barcodes array format, rawValue:", rawValue);
} else if (content?.rawValue) {
rawValue = content.rawValue;
logger.log("Processing object format, rawValue:", rawValue);
} else if (typeof content === "string") {
rawValue = content;
logger.log("Processing string format, rawValue:", rawValue);
}
// Handle direct string format
else if (typeof content === "string") {
url = content;
if (!rawValue) {
logger.error("No valid QR code content found in:", content);
this.danger("No QR code detected. Please try again.", "Scan Error");
return;
}
if (!url) {
logger.error("No valid QR code URL detected");
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
5000,
// Validate URL format first
if (!rawValue.startsWith("http://") && !rawValue.startsWith("https://")) {
logger.error("Invalid URL format:", rawValue);
this.danger(
"Invalid QR code format. Please scan a valid TimeSafari contact QR code.",
"Invalid Format",
);
return;
}
let newContact: Contact;
try {
logger.log("Attempting to extract JWT from URL:", url);
const jwt = getContactJwtFromJwtUrl(url);
// Extract JWT from URL
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.error("Failed to extract JWT from URL");
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "Could not extract contact information from QR code.",
},
3000,
logger.error("Failed to extract JWT from URL:", rawValue);
this.danger(
"Could not extract contact information from the QR code. Please try again.",
"Invalid QR Code",
);
return;
}
logger.log("Successfully extracted JWT, attempting to decode");
const { payload } = decodeEndorserJwt(jwt);
// Log JWT for debugging
logger.log("Extracted JWT:", jwt);
// Validate JWT format
if (
!jwt.match(/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/)
) {
logger.error("Invalid JWT format:", jwt);
this.danger(
"The QR code contains invalid data. Please scan a valid TimeSafari contact QR code.",
"Invalid Data",
);
return;
}
const { payload } = decodeEndorserJwt(jwt);
if (!payload) {
logger.error("JWT payload is null or undefined");
this.danger("Invalid JWT format", "Contact Error");
logger.error("Failed to decode JWT payload");
this.danger(
"Could not decode the contact information. Please try again.",
"Decode Error",
);
return;
}
if (!payload.own) {
logger.error("JWT payload missing 'own' property");
this.danger("Contact information is incomplete", "Contact Error");
// Log decoded payload for debugging
logger.log("Decoded JWT payload:", JSON.stringify(payload, null, 2));
// Validate required fields
if (!payload.own && !payload.iss) {
logger.error("Missing required fields in payload:", payload);
this.danger(
"Missing required contact information. Please scan a valid TimeSafari contact QR code.",
"Incomplete Data",
);
return;
}
newContact = {
did: payload.own.did || payload.iss,
name: payload.own.name || "",
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash || "",
profileImageUrl: payload.own.profileImageUrl || "",
publicKeyBase64: payload.own.publicEncKey || "",
registered: payload.own.registered || false,
did: payload.own?.did || payload.iss,
name: payload.own?.name,
nextPubKeyHashB64: payload.own?.nextPublicEncKeyHash,
profileImageUrl: payload.own?.profileImageUrl,
publicKeyBase64: payload.own?.publicEncKey,
registered: payload.own?.registered,
};
if (!newContact.did) {
logger.error("Contact missing DID");
this.danger("Contact is missing identifier", "Invalid Contact");
this.danger(
"Missing contact identifier. Please scan a valid TimeSafari contact QR code.",
"Incomplete Contact",
);
return;
}
if (!isDid(newContact.did)) {
logger.error("Invalid DID format:", newContact.did);
this.danger("Invalid contact identifier format", "Invalid Contact");
this.danger(
"Invalid contact identifier format. The identifier must begin with 'did:'.",
"Invalid Identifier",
);
return;
}
logger.log("Saving new contact:", {
...newContact,
publicKeyBase64: "[REDACTED]",
});
await db.open();
await db.contacts.add(newContact);
@ -754,15 +772,10 @@ export default class ContactQRScanShowView extends Vue {
}, 500);
}
} catch (e) {
logger.error("Error processing contact QR code:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not process contact information. Please try scanning again.",
},
5000,
logger.error("Error processing QR code:", e);
this.danger(
"Could not process the QR code. Please make sure you're scanning a valid TimeSafari contact QR code.",
"Processing Error",
);
}
}
@ -985,21 +998,5 @@ export default class ContactQRScanShowView extends Vue {
this.state.error = errorMessage;
this.state.scannerState.error = errorMessage;
}
async beforeDestroy() {
logger.log(
"ContactQRScanShow component being destroyed, initiating cleanup",
);
// Clean up scanner
await Promise.all([
this.stopScanning(),
this.cleanupScanListener(),
this.cleanupAppListeners(),
this.cleanupCamera(),
]).catch((error) => {
logger.error("Error during component cleanup:", error);
});
}
}
</script>

Loading…
Cancel
Save