refactor(qr): improve QR code scanning robustness and error handling

- Enhance JWT extraction with unified path handling and validation
- Add debouncing to prevent duplicate scans
- Improve error handling and logging throughout QR flow
- Add proper TypeScript interfaces for QR scan results
- Implement mobile app lifecycle handlers (pause/resume)
- Enhance logging with structured data and consistent levels
- Clean up scanner resources properly on component destroy
- Split contact handling into separate method for better organization
- Add proper type for UserNameDialog ref

This commit improves the reliability and maintainability of the QR code
scanning functionality while adding better error handling and logging.
This commit is contained in:
Matthew Raymer
2025-04-22 11:04:56 +00:00
parent a8812714a3
commit 30e448faf8
7 changed files with 327 additions and 159 deletions

View File

@@ -92,9 +92,9 @@ interface ScanProps {
}, },
}) })
export default class QRScannerDialog extends Vue { export default class QRScannerDialog extends Vue {
@Prop({ type: Function, required: true }) onScan!: ScanProps['onScan']; @Prop({ type: Function, required: true }) onScan!: ScanProps["onScan"];
@Prop({ type: Function }) onError?: ScanProps['onError']; @Prop({ type: Function }) onError?: ScanProps["onError"];
@Prop({ type: Object }) options?: ScanProps['options']; @Prop({ type: Object }) options?: ScanProps["options"];
visible = true; visible = true;
error: string | null = null; error: string | null = null;
@@ -132,7 +132,8 @@ export default class QRScannerDialog extends Vue {
await promise; await promise;
this.error = null; this.error = null;
} catch (error) { } catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error)); const wrappedError =
error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message; this.error = wrappedError.message;
if (this.onError) { if (this.onError) {
this.onError(wrappedError); this.onError(wrappedError);
@@ -146,7 +147,8 @@ export default class QRScannerDialog extends Vue {
this.onScan(result); this.onScan(result);
this.close(); this.close();
} catch (error) { } catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error)); const wrappedError =
error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message; this.error = wrappedError.message;
if (this.onError) { if (this.onError) {
this.onError(wrappedError); this.onError(wrappedError);

View File

@@ -9,6 +9,7 @@ import {
createEndorserJwtForDid, createEndorserJwtForDid,
CONTACT_URL_PATH_ENDORSER_CH_OLD, CONTACT_URL_PATH_ENDORSER_CH_OLD,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
} from "../../libs/endorserServer"; } from "../../libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
@@ -104,34 +105,41 @@ export const accessToken = async (did?: string) => {
}; };
/** /**
@return payload of JWT pulled out of any recognized URL path (if any) * Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT
* @returns The extracted JWT or null if not found
*/ */
export const getContactJwtFromJwtUrl = (jwtUrlText: string) => { export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText; try {
const appImportConfirmUrlLoc = jwtText.indexOf( let jwtText = jwtUrlText;
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
); // Try to extract JWT from URL paths
if (appImportConfirmUrlLoc > -1) { const paths = [
jwtText = jwtText.substring( CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
appImportConfirmUrlLoc + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI.length, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
); CONTACT_URL_PATH_ENDORSER_CH_OLD,
];
for (const path of paths) {
const pathIndex = jwtText.indexOf(path);
if (pathIndex > -1) {
jwtText = jwtText.substring(pathIndex + path.length);
break;
}
}
// Validate JWT format
if (!jwtText.match(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/)) {
logger.error("Invalid JWT format in URL:", jwtUrlText);
return null;
}
return jwtText;
} catch (error) {
logger.error("Error extracting JWT from URL:", error);
return null;
} }
const appImportOneUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
);
if (appImportOneUrlLoc > -1) {
jwtText = jwtText.substring(
appImportOneUrlLoc + CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI.length,
);
}
const endorserUrlPathLoc = jwtText.indexOf(CONTACT_URL_PATH_ENDORSER_CH_OLD);
if (endorserUrlPathLoc > -1) {
jwtText = jwtText.substring(
endorserUrlPathLoc + CONTACT_URL_PATH_ENDORSER_CH_OLD.length,
);
}
return jwtText;
}; };
export const nextDerivationPath = (origDerivPath: string) => { export const nextDerivationPath = (origDerivPath: string) => {

View File

@@ -86,6 +86,12 @@ export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=";
*/ */
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt="; export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";
/**
* URL path suffix for contact confirmation
* @constant {string}
*/
export const CONTACT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact/confirm/";
/** /**
* The prefix for handle IDs, the permanent ID for claims on Endorser * The prefix for handle IDs, the permanent ID for claims on Endorser
* @constant {string} * @constant {string}

View File

@@ -86,15 +86,18 @@ export class CapacitorQRScanner implements QRScannerService {
}; };
logger.log("Scanner options:", scanOptions); logger.log("Scanner options:", scanOptions);
// Add listener for barcode scans // Add listener for barcode scans
const handle = await BarcodeScanner.addListener('barcodeScanned', (result) => { const handle = await BarcodeScanner.addListener(
if (this.scanListener) { "barcodeScanned",
this.scanListener.onScan(result.barcode.rawValue); (result) => {
} 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);
} catch (error) { } catch (error) {

View File

@@ -13,13 +13,18 @@ export class QRScannerFactory {
private static isNativePlatform(): boolean { private static isNativePlatform(): boolean {
// Debug logging for build flags // Debug logging for build flags
logger.log("Build flags:", { logger.log("Build flags:", {
IS_MOBILE: typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : 'undefined', IS_MOBILE:
USE_QR_READER: typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : 'undefined', 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, VITE_PLATFORM: process.env.VITE_PLATFORM,
}); });
const capacitorNative = Capacitor.isNativePlatform(); const capacitorNative = Capacitor.isNativePlatform();
const isMobile = typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : capacitorNative; const isMobile =
typeof __IS_MOBILE__ !== "undefined" ? __IS_MOBILE__ : capacitorNative;
const platform = Capacitor.getPlatform(); const platform = Capacitor.getPlatform();
logger.log("Platform detection:", { logger.log("Platform detection:", {
@@ -37,7 +42,10 @@ export class QRScannerFactory {
// For other platforms, use native if available // For other platforms, use native if available
const useNative = capacitorNative || isMobile; const useNative = capacitorNative || isMobile;
logger.log("Platform decision:", { useNative, reason: useNative ? "capacitorNative/isMobile" : "web" }); logger.log("Platform decision:", {
useNative,
reason: useNative ? "capacitorNative/isMobile" : "web",
});
return useNative; return useNative;
} }
@@ -55,7 +63,11 @@ export class QRScannerFactory {
if (isNative) { if (isNative) {
logger.log("Using native MLKit scanner"); logger.log("Using native MLKit scanner");
this.instance = new CapacitorQRScanner(); this.instance = new CapacitorQRScanner();
} else if (typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : !isNative) { } else if (
typeof __USE_QR_READER__ !== "undefined"
? __USE_QR_READER__
: !isNative
) {
logger.log("Using web QR scanner"); logger.log("Using web QR scanner");
this.instance = new WebDialogQRScanner(); this.instance = new WebDialogQRScanner();
} else { } else {

View File

@@ -31,6 +31,17 @@ export const logger = {
logToDb(message + argsString); logToDb(message + argsString);
} }
}, },
info: (message: string, ...args: unknown[]) => {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor"
) {
// eslint-disable-next-line no-console
console.info(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
}
},
warn: (message: string, ...args: unknown[]) => { warn: (message: string, ...args: unknown[]) => {
if ( if (
process.env.NODE_ENV !== "production" || process.env.NODE_ENV !== "production" ||

View File

@@ -78,10 +78,12 @@
<div class="text-center"> <div class="text-center">
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1> <h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
<div v-if="isScanning" class="relative aspect-square"> <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
class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none"
></div>
</div> </div>
<div v-else> <div v-else>
<button <button
class="bg-blue-500 text-white px-4 py-2 rounded-md mt-4" class="bg-blue-500 text-white px-4 py-2 rounded-md mt-4"
@click="startScanning" @click="startScanning"
> >
@@ -122,6 +124,15 @@ import { Router } from "vue-router";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory"; import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
interface QRScanResult {
rawValue?: string;
barcode?: string;
}
interface IUserNameDialog {
open: (callback: (name: string) => void) => void;
}
@Component({ @Component({
components: { components: {
QRCodeVue3, QRCodeVue3,
@@ -144,6 +155,11 @@ export default class ContactQRScanShow extends Vue {
ETHR_DID_PREFIX = ETHR_DID_PREFIX; ETHR_DID_PREFIX = ETHR_DID_PREFIX;
// Add new properties to track scanning state
private lastScannedValue: string = "";
private lastScanTime: number = 0;
private readonly SCAN_DEBOUNCE_MS = 2000; // Prevent duplicate scans within 2 seconds
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
@@ -173,9 +189,11 @@ export default class ContactQRScanShow extends Vue {
try { try {
this.error = null; this.error = null;
this.isScanning = true; this.isScanning = true;
this.lastScannedValue = "";
this.lastScanTime = 0;
const scanner = QRScannerFactory.getInstance(); const scanner = QRScannerFactory.getInstance();
// Check permissions first // Check permissions first
if (!(await scanner.checkPermissions())) { if (!(await scanner.checkPermissions())) {
const granted = await scanner.requestPermissions(); const granted = await scanner.requestPermissions();
@@ -185,13 +203,13 @@ export default class ContactQRScanShow extends Vue {
return; return;
} }
} }
// Add scan listener // Add scan listener
scanner.addListener({ scanner.addListener({
onScan: this.onScanDetect, onScan: this.onScanDetect,
onError: this.onScanError onError: this.onScanError,
}); });
// Start scanning // Start scanning
await scanner.startScan(); await scanner.startScan();
} catch (error) { } catch (error) {
@@ -205,10 +223,11 @@ export default class ContactQRScanShow extends Vue {
try { try {
const scanner = QRScannerFactory.getInstance(); const scanner = QRScannerFactory.getInstance();
await scanner.stopScan(); await scanner.stopScan();
this.isScanning = false;
this.lastScannedValue = "";
this.lastScanTime = 0;
} catch (error) { } catch (error) {
logger.error("Error stopping scan:", error); logger.error("Error stopping scan:", error);
} finally {
this.isScanning = false;
} }
} }
@@ -225,116 +244,104 @@ export default class ContactQRScanShow extends Vue {
} }
/** /**
* Handle QR code scan result * Handle QR code scan result with debouncing to prevent duplicate scans
*/ */
async onScanDetect(result: string) { async onScanDetect(result: string | QRScanResult) {
try { try {
let newContact: Contact; // Extract raw value from different possible formats
const jwt = getContactJwtFromJwtUrl(result); const rawValue = typeof result === 'string' ? result : (result?.rawValue || result?.barcode);
if (!rawValue) {
logger.warn("Invalid scan result - no value found:", result);
return;
}
// Debounce duplicate scans
const now = Date.now();
if (
rawValue === this.lastScannedValue &&
now - this.lastScanTime < this.SCAN_DEBOUNCE_MS
) {
logger.info("Ignoring duplicate scan:", rawValue);
return;
}
// Update scan tracking
this.lastScannedValue = rawValue;
this.lastScanTime = now;
logger.info("Processing QR code scan result:", rawValue);
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) { if (!jwt) {
this.$notify( logger.warn("Invalid QR code format - no JWT found in URL");
{ this.$notify({
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "No Contact Info", title: "Invalid QR Code",
text: "The contact info could not be parsed.", text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
}, });
3000,
);
return; return;
} }
const { payload } = decodeEndorserJwt(jwt);
newContact = { // Process JWT and contact info
did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49 logger.info("Decoding JWT payload from QR code");
name: payload.own.name, const decodedJwt = await decodeEndorserJwt(jwt);
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash, if (!decodedJwt?.payload?.own) {
profileImageUrl: payload.own.profileImageUrl, logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
if (!contactInfo.did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
const contact = {
did: contactInfo.did,
name: contactInfo.name || "",
email: contactInfo.email || "",
phone: contactInfo.phone || "",
company: contactInfo.company || "",
title: contactInfo.title || "",
notes: contactInfo.notes || "",
}; };
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;
}
try { // Add contact and stop scanning
await db.open(); logger.info("Adding new contact to database:", {
await db.contacts.add(newContact); did: contact.did,
name: contact.name,
let addedMessage; });
if (this.activeDid) { await this.addNewContact(contact);
await this.setVisibility(newContact, true);
newContact.seesMe = true; // didn't work inside setVisibility
addedMessage =
"They were added, and your activity is visible to them.";
} else {
addedMessage = "They were added.";
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: addedMessage,
},
3000,
);
if (this.isRegistered) {
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
}
} catch (e) {
logger.error("Error saving contact info:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact info. Check if it already exists.",
},
5000,
);
}
// Stop scanning after successful scan
await this.stopScanning(); await this.stopScanning();
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : String(error); logger.error("Error processing contact QR code:", {
logger.error("Error processing scan result:", error); error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text:
error instanceof Error
? error.message
: "Could not process QR code. Please try again.",
});
} }
} }
@@ -350,11 +357,15 @@ export default class ContactQRScanShow extends Vue {
if (result.error) { if (result.error) {
this.danger(result.error as string, "Error Setting Visibility"); this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) { } else if (!result.success) {
logger.error("Got strange result from setting visibility:", result); logger.warn("Unexpected result from setting visibility:", result);
} }
} }
async register(contact: Contact) { async register(contact: Contact) {
logger.info("Submitting contact registration", {
did: contact.did,
name: contact.name,
});
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -375,6 +386,7 @@ export default class ContactQRScanShow extends Vue {
if (regResult.success) { if (regResult.success) {
contact.registered = true; contact.registered = true;
db.contacts.update(contact.did, { registered: true }); db.contacts.update(contact.did, { registered: true });
logger.info("Contact registration successful", { did: contact.did });
this.$notify( this.$notify(
{ {
@@ -400,12 +412,21 @@ export default class ContactQRScanShow extends Vue {
); );
} }
} catch (error) { } catch (error) {
logger.error("Error when registering:", error); logger.error("Error registering contact:", {
did: contact.did,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
let userMessage = "There was an error."; let userMessage = "There was an error.";
const serverError = error as AxiosError; const serverError = error as AxiosError;
if (serverError) { if (serverError) {
if (serverError.response?.data && typeof serverError.response.data === 'object' && 'message' in serverError.response.data) { if (
userMessage = (serverError.response.data as {message: string}).message; 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) { } else if (serverError.message) {
userMessage = serverError.message; // Info for the user userMessage = serverError.message; // Info for the user
} else { } else {
@@ -429,7 +450,10 @@ export default class ContactQRScanShow extends Vue {
onScanError(error: Error) { onScanError(error: Error) {
this.error = error.message; this.error = error.message;
logger.error("Scan error:", error); logger.error("QR code scan error:", {
error: error.message,
stack: error.stack,
});
} }
onCopyUrlToClipboard() { onCopyUrlToClipboard() {
@@ -468,15 +492,117 @@ export default class ContactQRScanShow extends Vue {
} }
openUserNameDialog() { openUserNameDialog() {
(this.$refs.userNameDialog as any).open((name: string) => { (this.$refs.userNameDialog as IUserNameDialog).open((name: string) => {
this.givenName = name; this.givenName = name;
}); });
} }
beforeDestroy() { beforeDestroy() {
// Clean up scanner when component is destroyed logger.info("Cleaning up QR scanner resources");
this.stopScanning(); // Ensure scanner is stopped
QRScannerFactory.cleanup(); QRScannerFactory.cleanup();
} }
async addNewContact(contact: Contact) {
try {
logger.info("Opening database connection for new contact");
await db.open();
await db.contacts.add(contact);
if (this.activeDid) {
logger.info("Setting contact visibility", { did: contact.did });
await this.setVisibility(contact, true);
contact.seesMe = true;
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: this.activeDid
? "They were added, and your activity is visible to them."
: "They were added.",
},
3000,
);
if (
this.isRegistered &&
!this.hideRegisterPromptOnNewContact &&
!contact.registered
) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(contact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
} catch (error) {
logger.error("Error saving contact to database:", {
did: contact.did,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact. Check if it already exists.",
},
5000,
);
}
}
// 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>