Browse Source

WIP: Unified contact QR code display + capture

qrcode-reboot
Jose Olarte III 3 days ago
parent
commit
79707d2811
  1. 5
      src/services/QRScanner/CapacitorQRScanner.ts
  2. 4
      src/services/QRScanner/QRScannerFactory.ts
  3. 17
      src/services/QRScanner/QRScannerService.ts
  4. 195
      src/services/QRScanner/WebInlineQRScanner.ts
  5. 277
      src/views/ContactQRScanShowView.vue

5
src/services/QRScanner/CapacitorQRScanner.ts

@ -202,4 +202,9 @@ export class CapacitorQRScanner implements QRScannerService {
return this.cleanupPromise;
}
onStream(callback: (stream: MediaStream | null) => void): void {
// No-op for native scanner
callback(null);
}
}

4
src/services/QRScanner/QRScannerFactory.ts

@ -1,7 +1,7 @@
import { Capacitor } from "@capacitor/core";
import { QRScannerService } from "./types";
import { CapacitorQRScanner } from "./CapacitorQRScanner";
import { WebDialogQRScanner } from "./WebDialogQRScanner";
import { WebInlineQRScanner } from "./WebInlineQRScanner";
import { logger } from "@/utils/logger";
/**
@ -69,7 +69,7 @@ export class QRScannerFactory {
: !isNative
) {
logger.log("Using web QR scanner");
this.instance = new WebDialogQRScanner();
this.instance = new WebInlineQRScanner();
} else {
throw new Error(
"No QR scanner implementation available for this platform",

17
src/services/QRScanner/QRScannerService.ts

@ -0,0 +1,17 @@
import { EventEmitter } from "events";
export interface QRScannerListener {
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: QRScannerListener): void;
cleanup(): Promise<void>;
onStream(callback: (stream: MediaStream | null) => void): void;
}

195
src/services/QRScanner/WebInlineQRScanner.ts

@ -0,0 +1,195 @@
import { QRScannerService, ScanListener, QRScannerOptions } from "./types";
import { logger } from "@/utils/logger";
import { EventEmitter } from "events";
export class WebInlineQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null;
private isScanning = false;
private stream: MediaStream | null = null;
private events = new EventEmitter();
constructor(private options?: QRScannerOptions) {}
async checkPermissions(): Promise<boolean> {
try {
logger.log("[QRScanner] Checking camera permissions...");
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
logger.log("[QRScanner] Permission state:", permissions.state);
return permissions.state === "granted";
} catch (error) {
logger.error("[QRScanner] Error checking camera permissions:", error);
return false;
}
}
async requestPermissions(): Promise<boolean> {
try {
// First check if we have any video devices
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(
(device) => device.kind === "videoinput",
);
if (videoDevices.length === 0) {
logger.error("No video devices found");
throw new Error("No camera found on this device");
}
// Try to get a stream with specific constraints
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment",
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
// Stop the test stream immediately
stream.getTracks().forEach((track) => track.stop());
return true;
} catch (error) {
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error requesting camera permissions:", {
error: wrappedError.message,
stack: wrappedError.stack,
name: wrappedError.name,
});
// Provide more specific error messages
if (
wrappedError.name === "NotFoundError" ||
wrappedError.name === "DevicesNotFoundError"
) {
throw new Error("No camera found on this device");
} else if (
wrappedError.name === "NotAllowedError" ||
wrappedError.name === "PermissionDeniedError"
) {
throw new Error(
"Camera access denied. Please grant camera permission and try again",
);
} else if (
wrappedError.name === "NotReadableError" ||
wrappedError.name === "TrackStartError"
) {
throw new Error("Camera is in use by another application");
} else {
throw new Error(`Camera error: ${wrappedError.message}`);
}
}
}
async isSupported(): Promise<boolean> {
try {
// Check for secure context first
if (!window.isSecureContext) {
logger.warn("Camera access requires HTTPS (secure context)");
return false;
}
// Check for camera API support
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
logger.warn("Camera API not supported in this browser");
return false;
}
// Check if we have any video devices
const devices = await navigator.mediaDevices.enumerateDevices();
const hasVideoDevices = devices.some(
(device) => device.kind === "videoinput",
);
if (!hasVideoDevices) {
logger.warn("No video devices found");
return false;
}
return true;
} catch (error) {
logger.error("Error checking camera support:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
}
async startScan(): Promise<void> {
if (this.isScanning) {
return;
}
try {
this.isScanning = true;
logger.log("[WebInlineQRScanner] Starting scan");
// Get camera stream
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment",
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
// Emit stream to component
this.events.emit("stream", this.stream);
} catch (error) {
this.isScanning = false;
const wrappedError =
error instanceof Error ? error : new Error(String(error));
if (this.scanListener?.onError) {
this.scanListener.onError(wrappedError);
}
logger.error("Error starting scan:", wrappedError);
throw wrappedError;
}
}
async stopScan(): Promise<void> {
if (!this.isScanning) {
return;
}
try {
logger.log("[WebInlineQRScanner] Stopping scan");
// Stop all tracks in the stream
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
// Emit stream stopped event
this.events.emit("stream", null);
} catch (error) {
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error stopping scan:", wrappedError);
throw wrappedError;
} finally {
this.isScanning = false;
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener;
}
// Add method to get stream events
onStream(callback: (stream: MediaStream | null) => void): void {
this.events.on("stream", callback);
}
async cleanup(): Promise<void> {
try {
await this.stopScan();
this.events.removeAllListeners();
} catch (error) {
logger.error("Error during cleanup:", error);
}
}
}

277
src/views/ContactQRScanShowView.vue

@ -2,37 +2,35 @@
<QuickNav selected="Profile" />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="relative px-7">
<h1
<div class="mb-2">
<h1 class="text-2xl text-center font-light relative px-7">
<!-- Back -->
<a
class="text-lg text-center font-light px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</h1>
</div>
</a>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info
</h1>
<p
v-if="!givenName"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<span class="text-red">Beware!</span>
You aren't sharing your name, so quickly
<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="openUserNameDialog"
>
click here to set it for them.
</span>
</p>
</div>
<p
v-if="!givenName"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<span class="text-red">Beware!</span>
You aren't sharing your name, so quickly
<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="openUserNameDialog"
>
click here to set it for them.
</span>
</p>
<UserNameDialog ref="userNameDialog" />
<div
@ -76,11 +74,114 @@
</div>
<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">
<h1 class="text-2xl text-center font-light pt-6">Scan Contact Info</h1>
<div v-if="isScanning" class="relative aspect-square max-w-sm mx-auto">
<!-- Status Message -->
<div
class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-center py-2 z-10"
>
<div
v-if="isInitializing"
class="flex items-center justify-center space-x-2"
>
<svg
class="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>{{ initializationStatus }}</span>
</div>
<p
v-else-if="isScanning"
class="flex items-center justify-center space-x-2"
>
<span
class="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse"
></span>
<span>Position QR code in the frame</span>
</p>
<p v-else-if="error" class="text-red-300">
<span class="font-medium">Error:</span> {{ error }}
</p>
<p v-else class="flex items-center justify-center space-x-2">
<span
class="inline-block w-2 h-2 bg-blue-500 rounded-full"
></span>
<span>Ready to scan</span>
</p>
</div>
<qrcode-stream
v-if="useQRReader && !isNativePlatform"
:camera="preferredCamera"
@decode="onDecode"
@init="onInit"
@detect="onDetect"
@error="onError"
@camera-on="onCameraOn"
@camera-off="onCameraOff"
/>
<!-- Scanning Frame -->
<div
class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none"
class="absolute inset-0 border-2"
:class="{
'border-blue-500': !error && !isScanning,
'border-green-500 animate-pulse': isScanning,
'border-red-500': error,
}"
style="opacity: 0.5; pointer-events: none"
></div>
<!-- Debug Info -->
<div
class="absolute bottom-16 left-0 right-0 bg-black bg-opacity-50 text-white text-xs text-center py-1"
>
Camera: {{ preferredCamera === "user" ? "Front" : "Back" }} |
Status: {{ cameraStatus }}
</div>
<!-- Camera Switch Button -->
<button
class="absolute bottom-4 right-4 bg-white rounded-full p-2 shadow-lg"
title="Switch camera"
@click="toggleCamera"
>
<svg
class="h-6 w-6 text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
<div v-else>
<button
@ -104,14 +205,6 @@
permissions.
</span>
</div>
<!-- QR Scanner Dialog for Web -->
<QRScannerDialog
v-if="showScannerDialog"
:on-scan="onScanDetect"
:on-error="onScanError"
:on-close="closeScannerDialog"
/>
</section>
</template>
@ -121,10 +214,10 @@ import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { Capacitor } from "@capacitor/core";
import { QrcodeStream } from "vue-qrcode-reader";
import QuickNav from "../components/QuickNav.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import QRScannerDialog from "../components/QRScanner/QRScannerDialog.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
@ -139,7 +232,8 @@ 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";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { WebInlineQRScanner } from "@/services/QRScanner/WebInlineQRScanner";
interface QRScanResult {
rawValue?: string;
@ -155,7 +249,7 @@ interface IUserNameDialog {
QRCodeVue3,
QuickNav,
UserNameDialog,
QRScannerDialog,
QrcodeStream,
},
})
export default class ContactQRScanShow extends Vue {
@ -170,9 +264,15 @@ export default class ContactQRScanShow extends Vue {
qrValue = "";
isScanning = false;
error: string | null = null;
showScannerDialog = false;
isNativePlatform = Capacitor.isNativePlatform();
// QR Scanner properties
isInitializing = true;
initializationStatus = "Initializing camera...";
useQRReader = __USE_QR_READER__;
preferredCamera: "user" | "environment" = "environment";
cameraStatus = "Initializing";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
// Add new properties to track scanning state
@ -230,6 +330,8 @@ export default class ContactQRScanShow extends Vue {
try {
this.error = null;
this.isScanning = true;
this.isInitializing = true;
this.initializationStatus = "Initializing camera...";
this.lastScannedValue = "";
this.lastScanTime = 0;
@ -240,6 +342,7 @@ export default class ContactQRScanShow extends Vue {
this.error =
"Camera access requires HTTPS. Please use a secure connection.";
this.isScanning = false;
this.isInitializing = false;
this.$notify(
{
group: "alert",
@ -254,10 +357,12 @@ export default class ContactQRScanShow extends Vue {
// Check permissions first
if (!(await scanner.checkPermissions())) {
this.initializationStatus = "Requesting camera permission...";
const granted = await scanner.requestPermissions();
if (!granted) {
this.error = "Camera permission denied";
this.isScanning = false;
this.isInitializing = false;
// Show notification for better visibility
this.$notify(
{
@ -272,12 +377,6 @@ export default class ContactQRScanShow extends Vue {
}
}
// Show the scanner dialog for web
if (!this.isNativePlatform) {
this.showScannerDialog = true;
return;
}
// For native platforms, use the scanner service
scanner.addListener({
onScan: this.onScanDetect,
@ -289,6 +388,7 @@ export default class ContactQRScanShow extends Vue {
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
this.isScanning = false;
this.isInitializing = false;
logger.error("Error starting scan:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
@ -601,11 +701,6 @@ export default class ContactQRScanShow extends Vue {
});
}
closeScannerDialog() {
this.showScannerDialog = false;
this.isScanning = false;
}
// Lifecycle hooks
mounted() {
this.isMounted = true;
@ -734,6 +829,90 @@ export default class ContactQRScanShow extends Vue {
);
}
}
async onInit(promise: Promise<void>): Promise<void> {
logger.log("[QRScanner] onInit called");
if (this.isNativePlatform) {
logger.log("Skipping QR scanner initialization on native platform");
return;
}
try {
await promise;
this.isInitializing = false;
this.cameraStatus = "Ready";
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message;
this.cameraStatus = "Error";
this.isInitializing = false;
logger.error("Error during QR scanner initialization:", {
error: wrappedError.message,
stack: wrappedError.stack,
});
}
}
onCameraOn(): void {
this.cameraStatus = "Active";
this.isInitializing = false;
}
onCameraOff(): void {
this.cameraStatus = "Off";
}
onDetect(result: any): void {
this.isScanning = true;
this.cameraStatus = "Detecting";
try {
let rawValue: string | undefined;
if (Array.isArray(result) && result.length > 0 && "rawValue" in result[0]) {
rawValue = result[0].rawValue;
} else if (result && typeof result === "object" && "rawValue" in result) {
rawValue = result.rawValue;
}
if (rawValue) {
this.isInitializing = false;
this.initializationStatus = "QR code captured!";
this.onScanDetect(rawValue);
}
} catch (error) {
this.handleError(error);
} finally {
this.isScanning = false;
this.cameraStatus = "Active";
}
}
onDecode(result: string): void {
try {
this.isInitializing = false;
this.initializationStatus = "QR code captured!";
this.onScanDetect(result);
} catch (error) {
this.handleError(error);
}
}
toggleCamera(): void {
this.preferredCamera = this.preferredCamera === "user" ? "environment" : "user";
}
private handleError(error: unknown): void {
const wrappedError = error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message;
this.cameraStatus = "Error";
}
onError(error: Error): void {
this.error = error.message;
this.cameraStatus = "Error";
logger.error("QR code scan error:", {
error: error.message,
stack: error.stack,
});
}
}
</script>

Loading…
Cancel
Save