diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index ec740be6..e1e9924e 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -13,6 +13,7 @@ dependencies { implementation project(':capacitor-mlkit-barcode-scanning') implementation project(':capacitor-app') implementation project(':capacitor-camera') + implementation project(':capacitor-clipboard') implementation project(':capacitor-filesystem') implementation project(':capacitor-share') implementation project(':capawesome-capacitor-file-picker') diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index a95bd42f..6f389366 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -15,6 +15,10 @@ "pkg": "@capacitor/camera", "classpath": "com.capacitorjs.plugins.camera.CameraPlugin" }, + { + "pkg": "@capacitor/clipboard", + "classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin" + }, { "pkg": "@capacitor/filesystem", "classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin" diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 3c06dfe7..188fdd40 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -14,6 +14,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/ include ':capacitor-camera' project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android') +include ':capacitor-clipboard' +project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android') + include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') diff --git a/ios/App/Podfile b/ios/App/Podfile index da98dfe6..5c94c24a 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -15,6 +15,7 @@ def capacitor_pods pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera' + pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard' pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker' diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index fdd82e86..eb9dd861 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -5,6 +5,8 @@ PODS: - Capacitor - CapacitorCamera (6.1.2): - Capacitor + - CapacitorClipboard (6.0.2): + - Capacitor - CapacitorCommunitySqlite (6.0.2): - Capacitor - SQLCipher @@ -88,6 +90,7 @@ DEPENDENCIES: - "Capacitor (from `../../node_modules/@capacitor/ios`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)" - "CapacitorCamera (from `../../node_modules/@capacitor/camera`)" + - "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)" - "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" @@ -119,6 +122,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/app" CapacitorCamera: :path: "../../node_modules/@capacitor/camera" + CapacitorClipboard: + :path: "../../node_modules/@capacitor/clipboard" CapacitorCommunitySqlite: :path: "../../node_modules/@capacitor-community/sqlite" CapacitorCordova: @@ -136,6 +141,7 @@ SPEC CHECKSUMS: Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79 + CapacitorClipboard: 4443c3cdb7c77b1533dfe3ff0f9f7756aa8579df CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74 @@ -157,6 +163,6 @@ SPEC CHECKSUMS: SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: f987510f7383b04a1b09ea8472bdadcd88b6c924 +PODFILE CHECKSUM: 60f54b19c5a7a07343ab5ba9e5db49019fd86aa0 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index d6914554..bc08a192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@capacitor/app": "^6.0.0", "@capacitor/camera": "^6.0.0", "@capacitor/cli": "^6.2.0", + "@capacitor/clipboard": "^6.0.2", "@capacitor/core": "^6.2.0", "@capacitor/filesystem": "^6.0.0", "@capacitor/ios": "^6.2.0", @@ -2296,6 +2297,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@capacitor/clipboard": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/clipboard/-/clipboard-6.0.2.tgz", + "integrity": "sha512-jQ6UeFra5NP58THNZNb7HtzOZU7cHsjgrbQGVuMTgsK1uTILZpNeh+pfqHbKggba6KaNh5DAsJvEVQGpIR1VBA==", + "peerDependencies": { + "@capacitor/core": "^6.0.0" + } + }, "node_modules/@capacitor/core": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz", diff --git a/package.json b/package.json index eb68f859..500fbcc1 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "@capacitor/app": "^6.0.0", "@capacitor/camera": "^6.0.0", "@capacitor/cli": "^6.2.0", + "@capacitor/clipboard": "^6.0.2", "@capacitor/core": "^6.2.0", "@capacitor/filesystem": "^6.0.0", "@capacitor/ios": "^6.2.0", diff --git a/src/services/ClipboardService.ts b/src/services/ClipboardService.ts new file mode 100644 index 00000000..0e68f95b --- /dev/null +++ b/src/services/ClipboardService.ts @@ -0,0 +1,185 @@ +import { Capacitor } from "@capacitor/core"; +import { Clipboard } from "@capacitor/clipboard"; +import { useClipboard } from "@vueuse/core"; +import { logger } from "@/utils/logger"; + +/** + * Platform-agnostic clipboard service that handles both web and native platforms + * Provides reliable clipboard functionality across all platforms including iOS + */ +export class ClipboardService { + private static instance: ClipboardService | null = null; + + /** + * Get singleton instance of ClipboardService + */ + public static getInstance(): ClipboardService { + if (!ClipboardService.instance) { + ClipboardService.instance = new ClipboardService(); + } + return ClipboardService.instance; + } + + /** + * Copy text to clipboard with platform-specific handling + * + * @param text - The text to copy to clipboard + * @returns Promise that resolves when copy is complete + * @throws Error if copy operation fails + */ + public async copyToClipboard(text: string): Promise { + const platform = Capacitor.getPlatform(); + const isNative = Capacitor.isNativePlatform(); + + logger.debug("[ClipboardService] Copying to clipboard:", { + text: text.substring(0, 50) + (text.length > 50 ? "..." : ""), + platform, + isNative, + timestamp: new Date().toISOString(), + }); + + try { + if (isNative && (platform === "ios" || platform === "android")) { + // Use native Capacitor clipboard for mobile platforms + await this.copyNative(text); + } else { + // Use web clipboard API for web/desktop platforms + await this.copyWeb(text); + } + + logger.debug("[ClipboardService] Copy successful", { + platform, + timestamp: new Date().toISOString(), + }); + } catch (error) { + logger.error("[ClipboardService] Copy failed:", { + error: error instanceof Error ? error.message : String(error), + platform, + timestamp: new Date().toISOString(), + }); + throw error; + } + } + + /** + * Copy text using native Capacitor clipboard API + * + * @param text - The text to copy + * @returns Promise that resolves when copy is complete + */ + private async copyNative(text: string): Promise { + try { + await Clipboard.write({ + string: text, + }); + } catch (error) { + logger.error("[ClipboardService] Native copy failed:", { + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString(), + }); + throw new Error( + `Native clipboard copy failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Copy text using web clipboard API with fallback + * + * @param text - The text to copy + * @returns Promise that resolves when copy is complete + */ + private async copyWeb(text: string): Promise { + try { + // Try VueUse clipboard first (handles some edge cases) + const { copy } = useClipboard(); + await copy(text); + } catch (error) { + logger.warn( + "[ClipboardService] VueUse clipboard failed, trying native API:", + { + error: error instanceof Error ? error.message : String(error), + timestamp: new Date().toISOString(), + }, + ); + + // Fallback to native navigator.clipboard + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + throw new Error("Clipboard API not supported in this browser"); + } + } + } + + /** + * Read text from clipboard (platform-specific) + * + * @returns Promise that resolves to the clipboard text + * @throws Error if read operation fails + */ + public async readFromClipboard(): Promise { + const platform = Capacitor.getPlatform(); + const isNative = Capacitor.isNativePlatform(); + + try { + if (isNative && (platform === "ios" || platform === "android")) { + // Use native Capacitor clipboard for mobile platforms + const result = await Clipboard.read(); + return result.value || ""; + } else { + // Use web clipboard API for web/desktop platforms + if (navigator.clipboard && navigator.clipboard.readText) { + return await navigator.clipboard.readText(); + } else { + throw new Error("Clipboard read API not supported in this browser"); + } + } + } catch (error) { + logger.error("[ClipboardService] Read from clipboard failed:", { + error: error instanceof Error ? error.message : String(error), + platform, + timestamp: new Date().toISOString(), + }); + throw error; + } + } + + /** + * Check if clipboard is supported on current platform + * + * @returns boolean indicating if clipboard is supported + */ + public isSupported(): boolean { + const platform = Capacitor.getPlatform(); + const isNative = Capacitor.isNativePlatform(); + + if (isNative && (platform === "ios" || platform === "android")) { + return true; // Capacitor clipboard should work on native platforms + } + + // Check web clipboard support + return !!(navigator.clipboard && navigator.clipboard.writeText); + } +} + +/** + * Convenience function to copy text to clipboard + * Uses the singleton ClipboardService instance + * + * @param text - The text to copy to clipboard + * @returns Promise that resolves when copy is complete + */ +export async function copyToClipboard(text: string): Promise { + return ClipboardService.getInstance().copyToClipboard(text); +} + +/** + * Convenience function to read text from clipboard + * Uses the singleton ClipboardService instance + * + * @returns Promise that resolves to the clipboard text + */ +export async function readFromClipboard(): Promise { + return ClipboardService.getInstance().readFromClipboard(); +} diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 76280239..6269c3ff 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -140,7 +140,7 @@ import { AxiosError } from "axios"; import { Buffer } from "buffer/"; import QRCodeVue3 from "qr-code-generator-vue3"; import { Component, Vue } from "vue-facing-decorator"; -import { useClipboard } from "@vueuse/core"; + import { QrcodeStream } from "vue-qrcode-reader"; import QuickNav from "../components/QuickNav.vue"; @@ -183,8 +183,6 @@ import { NOTIFY_QR_PROCESSING_ERROR, createQRContactAddedMessage, createQRRegistrationSuccessMessage, - QR_TIMEOUT_SHORT, - QR_TIMEOUT_MEDIUM, QR_TIMEOUT_STANDARD, QR_TIMEOUT_LONG, } from "@/constants/notifications"; @@ -544,11 +542,7 @@ export default class ContactQRScanShow extends Vue { did: contact.did, name: contact.name, }); - this.notify.toast( - "Submitted", - NOTIFY_QR_REGISTRATION_SUBMITTED.message, - QR_TIMEOUT_SHORT, - ); + this.notify.toast("Submitted", NOTIFY_QR_REGISTRATION_SUBMITTED.message); try { const regResult = await register( @@ -624,18 +618,15 @@ export default class ContactQRScanShow extends Vue { ); // Copy the URL to clipboard - useClipboard() - .copy(jwtUrl) - .then(() => { - this.notify.toast( - "Copied", - NOTIFY_QR_URL_COPIED.message, - QR_TIMEOUT_MEDIUM, - ); - }); + const { copyToClipboard } = await import("../services/ClipboardService"); + await copyToClipboard(jwtUrl); + this.notify.toast( + NOTIFY_QR_URL_COPIED.title, + NOTIFY_QR_URL_COPIED.message, + ); } catch (error) { - logger.error("Failed to generate contact URL:", error); - this.notify.error("Failed to generate contact URL. Please try again."); + this.$logAndConsole(`Error copying URL to clipboard: ${error}`, true); + this.notify.error("Failed to copy URL to clipboard."); } } @@ -643,13 +634,16 @@ export default class ContactQRScanShow extends Vue { this.notify.info(NOTIFY_QR_CODE_HELP.message, QR_TIMEOUT_LONG); } - onCopyDidToClipboard() { + async onCopyDidToClipboard() { //this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing - useClipboard() - .copy(this.activeDid) - .then(() => { - this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG); - }); + try { + const { copyToClipboard } = await import("../services/ClipboardService"); + await copyToClipboard(this.activeDid); + this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG); + } catch (error) { + this.$logAndConsole(`Error copying DID to clipboard: ${error}`, true); + this.notify.error("Failed to copy DID to clipboard."); + } } openUserNameDialog() { @@ -745,7 +739,6 @@ export default class ContactQRScanShow extends Vue { ) { setTimeout(() => { this.notify.confirm( - "Register", "Do you want to register them?", { onCancel: async (stopAsking?: boolean) => { diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index 6c670f26..2ed7611f 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -130,10 +130,9 @@ import { JWTPayload } from "did-jwt"; import * as R from "ramda"; import { Component, Vue } from "vue-facing-decorator"; import { RouteLocationNormalizedLoaded, Router } from "vue-router"; -import { useClipboard } from "@vueuse/core"; -// Capacitor import removed - using PlatformService instead import QuickNav from "../components/QuickNav.vue"; +import { copyToClipboard } from "../services/ClipboardService"; import EntityIcon from "../components/EntityIcon.vue"; import GiftedDialog from "../components/GiftedDialog.vue"; import OfferDialog from "../components/OfferDialog.vue"; @@ -1192,12 +1191,14 @@ export default class ContactsView extends Vue { }); // Use production URL for sharing to avoid localhost issues in development const contactsJwtUrl = `${APP_SERVER}/deep-link/contact-import/${contactsJwt}`; - useClipboard() - .copy(contactsJwtUrl) - .then(() => { - // Use notification helper - this.notify.copied(NOTIFY_CONTACT_LINK_COPIED.message); - }); + try { + await copyToClipboard(contactsJwtUrl); + // Use notification helper + this.notify.copied(NOTIFY_CONTACT_LINK_COPIED.message); + } catch (error) { + this.$logAndConsole(`Error copying to clipboard: ${error}`, true); + this.notify.error("Failed to copy to clipboard. Please try again."); + } } private showCopySelectionsInfo() { diff --git a/src/views/ShareMyContactInfoView.vue b/src/views/ShareMyContactInfoView.vue index 2316b1d2..0476352e 100644 --- a/src/views/ShareMyContactInfoView.vue +++ b/src/views/ShareMyContactInfoView.vue @@ -144,8 +144,8 @@ export default class ShareMyContactInfoView extends Vue { * Copy the contact message to clipboard */ private async copyToClipboard(message: string): Promise { - const { useClipboard } = await import("@vueuse/core"); - await useClipboard().copy(message); + const { copyToClipboard } = await import("../services/ClipboardService"); + await copyToClipboard(message); } /**