fix(qr): improve QR scanner implementation and error handling

- Implement robust QR scanner factory with platform detection
- Add proper camera permissions to Android manifest
- Improve error handling and logging across scanner implementations
- Add continuous scanning mode for Capacitor/MLKit scanner
- Enhance UI feedback during scanning process
- Fix build configuration for proper platform detection
- Clean up resources properly in scanner components
- Add TypeScript improvements and error wrapping

The changes include:
- Adding CAMERA permission to AndroidManifest.xml
- Setting proper build flags (__IS_MOBILE__, __USE_QR_READER__)
- Implementing continuous scanning mode for better UX
- Adding proper cleanup of scanner resources
- Improving error handling and type safety
- Enhancing UI with loading states and error messages
This commit is contained in:
Matthew Raymer
2025-04-22 10:00:37 +00:00
parent 2855d4b8d5
commit a8812714a3
10 changed files with 548 additions and 451 deletions

View File

@@ -27,7 +27,7 @@
<br />
<span
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md"
@click="() => $refs.userNameDialog.open((name) => (givenName = name))"
@click="openUserNameDialog"
>
click here to set it for them.
</span>
@@ -77,8 +77,19 @@
<div class="text-center">
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
<span>
<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>
<div v-else>
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md mt-4"
@click="startScanning"
>
Start Scanning
</button>
</div>
<span v-if="error" class="text-red-500 block mt-2">{{ error }}</span>
<span v-else class="block mt-2">
If you do not see a scanning camera window here, check your camera
permissions.
</span>
@@ -90,7 +101,6 @@
import { AxiosError } from "axios";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { useClipboard } from "@vueuse/core";
import QuickNav from "../components/QuickNav.vue";
@@ -110,9 +120,10 @@ import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { retrieveAccountMetadata } from "../libs/util";
import { Router } from "vue-router";
import { logger } from "../utils/logger";
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
@Component({
components: {
QrcodeStream,
QRCodeVue3,
QuickNav,
UserNameDialog,
@@ -128,6 +139,8 @@ export default class ContactQRScanShow extends Vue {
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = "";
isScanning = false;
error: string | null = null;
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@@ -150,12 +163,55 @@ export default class ContactQRScanShow extends Vue {
account,
!!settings.isRegistered,
name,
settings.profileImageUrl,
settings.profileImageUrl || "",
false,
);
}
}
async startScanning() {
try {
this.error = null;
this.isScanning = true;
const scanner = QRScannerFactory.getInstance();
// Check permissions first
if (!(await scanner.checkPermissions())) {
const granted = await scanner.requestPermissions();
if (!granted) {
this.error = "Camera permission denied";
this.isScanning = false;
return;
}
}
// Add scan listener
scanner.addListener({
onScan: this.onScanDetect,
onError: this.onScanError
});
// Start scanning
await scanner.startScan();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
this.isScanning = false;
logger.error("Error starting scan:", error);
}
}
async stopScanning() {
try {
const scanner = QRScannerFactory.getInstance();
await scanner.stopScan();
} catch (error) {
logger.error("Error stopping scan:", error);
} finally {
this.isScanning = false;
}
}
danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
@@ -169,49 +225,37 @@ export default class ContactQRScanShow extends Vue {
}
/**
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
* Handle QR code scan result
*/
// 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) {
const url = content[0]?.rawValue;
if (url) {
async onScanDetect(result: string) {
try {
let newContact: Contact;
try {
const jwt = getContactJwtFromJwtUrl(url);
if (!jwt) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
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,
publicKeyBase64: payload.own.publicEncKey,
registered: payload.own.registered,
};
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;
}
} catch (e) {
logger.error("Error parsing QR info:", e);
this.danger("Could not parse the QR info.", "Read Error");
const jwt = getContactJwtFromJwtUrl(result);
if (!jwt) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
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,
};
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;
}
@@ -247,7 +291,7 @@ export default class ContactQRScanShow extends Vue {
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
@@ -255,7 +299,7 @@ export default class ContactQRScanShow extends Vue {
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking: boolean) => {
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
@@ -285,16 +329,12 @@ export default class ContactQRScanShow extends Vue {
5000,
);
}
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
5000,
);
// Stop scanning after successful scan
await this.stopScanning();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
logger.error("Error processing scan result:", error);
}
}
@@ -364,8 +404,8 @@ export default class ContactQRScanShow extends Vue {
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.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 {
@@ -387,18 +427,9 @@ export default class ContactQRScanShow extends Vue {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) {
logger.error("Scan was invalid:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid Scan",
text: "The scan was invalid.",
},
5000,
);
onScanError(error: Error) {
this.error = error.message;
logger.error("Scan error:", error);
}
onCopyUrlToClipboard() {
@@ -435,5 +466,22 @@ export default class ContactQRScanShow extends Vue {
);
});
}
openUserNameDialog() {
(this.$refs.userNameDialog as any).open((name: string) => {
this.givenName = name;
});
}
beforeDestroy() {
// Clean up scanner when component is destroyed
QRScannerFactory.cleanup();
}
}
</script>
<style scoped>
.aspect-square {
aspect-ratio: 1 / 1;
}
</style>