Browse Source

refactor(qr): improve QR scanner robustness and lifecycle management

- Add cleanup promise to prevent concurrent cleanup operations
- Add proper component lifecycle tracking with isMounted flag
- Add isCleaningUp flag to prevent operations during cleanup
- Add debug level logging for better diagnostics
- Add structured error logging with stack traces
- Add proper error handling in component initialization
- Add proper cleanup of event listeners and camera resources
- Add proper handling of app pause/resume events
- Add proper error boundaries around camera operations
- Improve error message formatting and consistency

The QR scanner now properly handles lifecycle events, cleans up resources,
and provides better error diagnostics. This improves reliability on mobile
devices and prevents potential memory leaks.
pull/132/head
Matthew Raymer 6 days ago
parent
commit
4abb188da3
  1. 129
      src/services/QRScanner/CapacitorQRScanner.ts
  2. 8
      src/utils/logger.ts
  3. 142
      src/views/ContactQRScanShowView.vue

129
src/services/QRScanner/CapacitorQRScanner.ts

@ -11,15 +11,16 @@ export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null; private scanListener: ScanListener | null = null;
private isScanning = false; private isScanning = false;
private listenerHandles: Array<() => Promise<void>> = []; private listenerHandles: Array<() => Promise<void>> = [];
private cleanupPromise: Promise<void> | null = null;
async checkPermissions(): Promise<boolean> { async checkPermissions(): Promise<boolean> {
try { try {
logger.debug("Checking camera permissions");
const { camera } = await BarcodeScanner.checkPermissions(); const { camera } = await BarcodeScanner.checkPermissions();
return camera === "granted"; return camera === "granted";
} catch (error) { } catch (error) {
const wrappedError = const wrappedError = error instanceof Error ? error : new Error(String(error));
error instanceof Error ? error : new Error(String(error)); logger.error("Error checking camera permissions:", { error: wrappedError.message });
logger.error("Error checking camera permissions:", wrappedError);
return false; return false;
} }
} }
@ -28,42 +29,50 @@ export class CapacitorQRScanner implements QRScannerService {
try { try {
// First check if we already have permissions // First check if we already have permissions
if (await this.checkPermissions()) { if (await this.checkPermissions()) {
logger.debug("Camera permissions already granted");
return true; return true;
} }
// Request permissions if we don't have them logger.debug("Requesting camera permissions");
const { camera } = await BarcodeScanner.requestPermissions(); const { camera } = await BarcodeScanner.requestPermissions();
return camera === "granted"; const granted = camera === "granted";
logger.debug(`Camera permissions ${granted ? "granted" : "denied"}`);
return granted;
} catch (error) { } catch (error) {
const wrappedError = const wrappedError = error instanceof Error ? error : new Error(String(error));
error instanceof Error ? error : new Error(String(error)); logger.error("Error requesting camera permissions:", { error: wrappedError.message });
logger.error("Error requesting camera permissions:", wrappedError);
return false; return false;
} }
} }
async isSupported(): Promise<boolean> { async isSupported(): Promise<boolean> {
try { try {
logger.debug("Checking scanner support");
const { supported } = await BarcodeScanner.isSupported(); const { supported } = await BarcodeScanner.isSupported();
logger.debug(`Scanner support: ${supported}`);
return supported; return supported;
} catch (error) { } catch (error) {
const wrappedError = const wrappedError = error instanceof Error ? error : new Error(String(error));
error instanceof Error ? error : new Error(String(error)); logger.error("Error checking scanner support:", { error: wrappedError.message });
logger.error("Error checking scanner support:", wrappedError);
return false; return false;
} }
} }
async startScan(options?: QRScannerOptions): Promise<void> { async startScan(options?: QRScannerOptions): Promise<void> {
if (this.isScanning) { if (this.isScanning) {
logger.debug("Scanner already running");
return; return;
} }
if (this.cleanupPromise) {
logger.debug("Waiting for previous cleanup to complete");
await this.cleanupPromise;
}
try { try {
// Ensure we have permissions before starting // Ensure we have permissions before starting
logger.log("Checking camera permissions...");
if (!(await this.checkPermissions())) { if (!(await this.checkPermissions())) {
logger.log("Requesting camera permissions..."); logger.debug("Requesting camera permissions");
const granted = await this.requestPermissions(); const granted = await this.requestPermissions();
if (!granted) { if (!granted) {
throw new Error("Camera permission denied"); throw new Error("Camera permission denied");
@ -71,39 +80,39 @@ export class CapacitorQRScanner implements QRScannerService {
} }
// Check if scanning is supported // Check if scanning is supported
logger.log("Checking scanner support...");
if (!(await this.isSupported())) { if (!(await this.isSupported())) {
throw new Error("QR scanning not supported on this device"); throw new Error("QR scanning not supported on this device");
} }
logger.log("Starting MLKit scanner..."); logger.info("Starting MLKit scanner");
this.isScanning = true; this.isScanning = true;
const scanOptions: StartScanOptions = { const scanOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode], formats: [BarcodeFormat.QrCode],
lensFacing: lensFacing: options?.camera === "front" ? LensFacing.Front : LensFacing.Back,
options?.camera === "front" ? LensFacing.Front : LensFacing.Back,
}; };
logger.log("Scanner options:", scanOptions); logger.debug("Scanner options:", scanOptions);
// Add listener for barcode scans // Add listener for barcode scans
const handle = await BarcodeScanner.addListener( const handle = await BarcodeScanner.addListener("barcodeScanned", (result) => {
"barcodeScanned", if (this.scanListener && result.barcode?.rawValue) {
(result) => { this.scanListener.onScan(result.barcode.rawValue);
if (this.scanListener) { }
this.scanListener.onScan(result.barcode.rawValue); });
}
},
);
this.listenerHandles.push(handle.remove); this.listenerHandles.push(handle.remove);
// Start continuous scanning // Start continuous scanning
await BarcodeScanner.startScan(scanOptions); await BarcodeScanner.startScan(scanOptions);
logger.info("MLKit scanner started successfully");
} catch (error) { } catch (error) {
const wrappedError = const wrappedError = error instanceof Error ? error : new Error(String(error));
error instanceof Error ? error : new Error(String(error)); logger.error("Error during QR scan:", {
logger.error("Error during QR scan:", wrappedError); error: wrappedError.message,
stack: wrappedError.stack
});
this.isScanning = false;
await this.cleanup();
this.scanListener?.onError?.(wrappedError); this.scanListener?.onError?.(wrappedError);
throw wrappedError; throw wrappedError;
} }
@ -111,15 +120,20 @@ export class CapacitorQRScanner implements QRScannerService {
async stopScan(): Promise<void> { async stopScan(): Promise<void> {
if (!this.isScanning) { if (!this.isScanning) {
logger.debug("Scanner not running");
return; return;
} }
try { try {
logger.debug("Stopping QR scanner");
await BarcodeScanner.stopScan(); await BarcodeScanner.stopScan();
logger.info("QR scanner stopped successfully");
} catch (error) { } catch (error) {
const wrappedError = const wrappedError = error instanceof Error ? error : new Error(String(error));
error instanceof Error ? error : new Error(String(error)); logger.error("Error stopping QR scan:", {
logger.error("Error stopping QR scan:", wrappedError); error: wrappedError.message,
stack: wrappedError.stack
});
this.scanListener?.onError?.(wrappedError); this.scanListener?.onError?.(wrappedError);
throw wrappedError; throw wrappedError;
} finally { } finally {
@ -132,19 +146,44 @@ export class CapacitorQRScanner implements QRScannerService {
} }
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
try { // Prevent multiple simultaneous cleanup attempts
await this.stopScan(); if (this.cleanupPromise) {
for (const handle of this.listenerHandles) { return this.cleanupPromise;
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;
} }
this.cleanupPromise = (async () => {
try {
logger.debug("Starting QR scanner cleanup");
// Stop scanning if active
if (this.isScanning) {
await this.stopScan();
}
// Remove all listeners
for (const handle of this.listenerHandles) {
try {
await handle();
} catch (error) {
logger.warn("Error removing listener:", error);
}
}
logger.info("QR scanner cleanup completed");
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
logger.error("Error during cleanup:", {
error: wrappedError.message,
stack: wrappedError.stack
});
throw wrappedError;
} finally {
this.listenerHandles = [];
this.scanListener = null;
this.cleanupPromise = null;
}
})();
return this.cleanupPromise;
} }
} }

8
src/utils/logger.ts

@ -20,6 +20,14 @@ function safeStringify(obj: unknown) {
} }
export const logger = { export const logger = {
debug: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.debug(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
}
},
log: (message: string, ...args: unknown[]) => { log: (message: string, ...args: unknown[]) => {
if ( if (
process.env.NODE_ENV !== "production" || process.env.NODE_ENV !== "production" ||

142
src/views/ContactQRScanShowView.vue

@ -160,32 +160,50 @@ export default class ContactQRScanShow extends Vue {
private lastScanTime: number = 0; private lastScanTime: number = 0;
private readonly SCAN_DEBOUNCE_MS = 2000; // Prevent duplicate scans within 2 seconds private readonly SCAN_DEBOUNCE_MS = 2000; // Prevent duplicate scans within 2 seconds
// Add cleanup tracking
private isCleaningUp = false;
private isMounted = false;
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); try {
this.activeDid = settings.activeDid || ""; const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || ""; this.activeDid = settings.activeDid || "";
this.givenName = settings.firstName || ""; this.apiServer = settings.apiServer || "";
this.hideRegisterPromptOnNewContact = this.givenName = settings.firstName || "";
!!settings.hideRegisterPromptOnNewContact; this.hideRegisterPromptOnNewContact = !!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
const account = await retrieveAccountMetadata(this.activeDid); const account = await retrieveAccountMetadata(this.activeDid);
if (account) { if (account) {
const name = const name = (settings.firstName || "") + (settings.lastName ? ` ${settings.lastName}` : "");
(settings.firstName || "") + this.qrValue = await generateEndorserJwtUrlForAccount(
(settings.lastName ? ` ${settings.lastName}` : ""); // lastName is deprecated, pre v 0.1.3 account,
!!settings.isRegistered,
this.qrValue = await generateEndorserJwtUrlForAccount( name,
account, settings.profileImageUrl || "",
!!settings.isRegistered, false,
name, );
settings.profileImageUrl || "", }
false, } catch (error) {
); logger.error("Error initializing component:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
this.$notify({
group: "alert",
type: "danger",
title: "Initialization Error",
text: "Failed to initialize QR scanner. Please try again.",
});
} }
} }
async startScanning() { async startScanning() {
if (this.isCleaningUp) {
logger.debug("Cannot start scanning during cleanup");
return;
}
try { try {
this.error = null; this.error = null;
this.isScanning = true; this.isScanning = true;
@ -215,7 +233,10 @@ export default class ContactQRScanShow extends Vue {
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : String(error); this.error = error instanceof Error ? error.message : String(error);
this.isScanning = false; this.isScanning = false;
logger.error("Error starting scan:", error); logger.error("Error starting scan:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
} }
} }
@ -223,11 +244,35 @@ export default class ContactQRScanShow extends Vue {
try { try {
const scanner = QRScannerFactory.getInstance(); const scanner = QRScannerFactory.getInstance();
await scanner.stopScan(); await scanner.stopScan();
} catch (error) {
logger.error("Error stopping scan:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
} finally {
this.isScanning = false; this.isScanning = false;
this.lastScannedValue = ""; this.lastScannedValue = "";
this.lastScanTime = 0; this.lastScanTime = 0;
}
}
async cleanupScanner() {
if (this.isCleaningUp) {
return;
}
this.isCleaningUp = true;
try {
logger.info("Cleaning up QR scanner resources");
await this.stopScanning();
await QRScannerFactory.cleanup();
} catch (error) { } catch (error) {
logger.error("Error stopping scan:", error); logger.error("Error during scanner cleanup:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
} finally {
this.isCleaningUp = false;
} }
} }
@ -497,10 +542,32 @@ export default class ContactQRScanShow extends Vue {
}); });
} }
// Lifecycle hooks
mounted() {
this.isMounted = true;
document.addEventListener("pause", this.handleAppPause);
document.addEventListener("resume", this.handleAppResume);
}
beforeDestroy() { beforeDestroy() {
logger.info("Cleaning up QR scanner resources"); this.isMounted = false;
this.stopScanning(); // Ensure scanner is stopped document.removeEventListener("pause", this.handleAppPause);
QRScannerFactory.cleanup(); document.removeEventListener("resume", this.handleAppResume);
this.cleanupScanner();
}
async handleAppPause() {
if (!this.isMounted) return;
logger.info("App paused, stopping scanner");
await this.stopScanning();
}
handleAppResume() {
if (!this.isMounted) return;
logger.info("App resumed, scanner can be restarted by user");
this.isScanning = false;
} }
async addNewContact(contact: Contact) { async addNewContact(contact: Contact) {
@ -581,28 +648,6 @@ export default class ContactQRScanShow extends Vue {
); );
} }
} }
// Add pause/resume handlers for mobile
mounted() {
document.addEventListener("pause", this.handleAppPause);
document.addEventListener("resume", this.handleAppResume);
}
beforeUnmount() {
document.removeEventListener("pause", this.handleAppPause);
document.removeEventListener("resume", this.handleAppResume);
}
handleAppPause() {
logger.info("App paused, stopping scanner");
this.stopScanning();
}
handleAppResume() {
logger.info("App resumed, scanner can be restarted by user");
// Don't auto-restart scanning - let user initiate it
this.isScanning = false;
}
} }
</script> </script>
@ -611,3 +656,4 @@ export default class ContactQRScanShow extends Vue {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
} }
</style> </style>

Loading…
Cancel
Save