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:
@@ -78,10 +78,12 @@
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
|
||||
<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 v-else>
|
||||
<button
|
||||
<button
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded-md mt-4"
|
||||
@click="startScanning"
|
||||
>
|
||||
@@ -122,6 +124,15 @@ import { Router } from "vue-router";
|
||||
import { logger } from "../utils/logger";
|
||||
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
barcode?: string;
|
||||
}
|
||||
|
||||
interface IUserNameDialog {
|
||||
open: (callback: (name: string) => void) => void;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QRCodeVue3,
|
||||
@@ -144,6 +155,11 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
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() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
@@ -173,9 +189,11 @@ export default class ContactQRScanShow extends Vue {
|
||||
try {
|
||||
this.error = null;
|
||||
this.isScanning = true;
|
||||
|
||||
this.lastScannedValue = "";
|
||||
this.lastScanTime = 0;
|
||||
|
||||
const scanner = QRScannerFactory.getInstance();
|
||||
|
||||
|
||||
// Check permissions first
|
||||
if (!(await scanner.checkPermissions())) {
|
||||
const granted = await scanner.requestPermissions();
|
||||
@@ -185,13 +203,13 @@ export default class ContactQRScanShow extends Vue {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add scan listener
|
||||
scanner.addListener({
|
||||
onScan: this.onScanDetect,
|
||||
onError: this.onScanError
|
||||
onError: this.onScanError,
|
||||
});
|
||||
|
||||
|
||||
// Start scanning
|
||||
await scanner.startScan();
|
||||
} catch (error) {
|
||||
@@ -205,10 +223,11 @@ export default class ContactQRScanShow extends Vue {
|
||||
try {
|
||||
const scanner = QRScannerFactory.getInstance();
|
||||
await scanner.stopScan();
|
||||
this.isScanning = false;
|
||||
this.lastScannedValue = "";
|
||||
this.lastScanTime = 0;
|
||||
} catch (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 {
|
||||
let newContact: Contact;
|
||||
const jwt = getContactJwtFromJwtUrl(result);
|
||||
// Extract raw value from different possible formats
|
||||
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) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "No Contact Info",
|
||||
text: "The contact info could not be parsed.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
|
||||
});
|
||||
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,
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
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 {
|
||||
await db.open();
|
||||
await db.contacts.add(newContact);
|
||||
|
||||
let addedMessage;
|
||||
if (this.activeDid) {
|
||||
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
|
||||
// Add contact and stop scanning
|
||||
logger.info("Adding new contact to database:", {
|
||||
did: contact.did,
|
||||
name: contact.name,
|
||||
});
|
||||
await this.addNewContact(contact);
|
||||
await this.stopScanning();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
logger.error("Error processing scan result:", error);
|
||||
logger.error("Error processing contact QR code:", {
|
||||
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) {
|
||||
this.danger(result.error as string, "Error Setting Visibility");
|
||||
} 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) {
|
||||
logger.info("Submitting contact registration", {
|
||||
did: contact.did,
|
||||
name: contact.name,
|
||||
});
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -375,6 +386,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
if (regResult.success) {
|
||||
contact.registered = true;
|
||||
db.contacts.update(contact.did, { registered: true });
|
||||
logger.info("Contact registration successful", { did: contact.did });
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
@@ -400,12 +412,21 @@ export default class ContactQRScanShow extends Vue {
|
||||
);
|
||||
}
|
||||
} 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.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (serverError.response?.data && typeof serverError.response.data === 'object' && 'message' in serverError.response.data) {
|
||||
userMessage = (serverError.response.data as {message: string}).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 {
|
||||
@@ -429,7 +450,10 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
onScanError(error: Error) {
|
||||
this.error = error.message;
|
||||
logger.error("Scan error:", error);
|
||||
logger.error("QR code scan error:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
|
||||
onCopyUrlToClipboard() {
|
||||
@@ -468,15 +492,117 @@ export default class ContactQRScanShow extends Vue {
|
||||
}
|
||||
|
||||
openUserNameDialog() {
|
||||
(this.$refs.userNameDialog as any).open((name: string) => {
|
||||
(this.$refs.userNameDialog as IUserNameDialog).open((name: string) => {
|
||||
this.givenName = name;
|
||||
});
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
// Clean up scanner when component is destroyed
|
||||
logger.info("Cleaning up QR scanner resources");
|
||||
this.stopScanning(); // Ensure scanner is stopped
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user