diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index b1b57f23eb..d8fbb5fc6d 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 7A1C0DE7C11B0A4D2E5F3901 /* AppViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1C0DE7C11B0A4D2E5F3902 /* AppViewController.swift */; }; + 7A1C0DE7C11B0A4D2E5F3903 /* ClipboardDetectPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1C0DE7C11B0A4D2E5F3904 /* ClipboardDetectPlugin.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; @@ -22,6 +24,8 @@ 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7A1C0DE7C11B0A4D2E5F3902 /* AppViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewController.swift; sourceTree = ""; }; + 7A1C0DE7C11B0A4D2E5F3904 /* ClipboardDetectPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardDetectPlugin.swift; sourceTree = ""; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -64,6 +68,8 @@ children = ( 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 7A1C0DE7C11B0A4D2E5F3902 /* AppViewController.swift */, + 7A1C0DE7C11B0A4D2E5F3904 /* ClipboardDetectPlugin.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -156,6 +162,8 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 7A1C0DE7C11B0A4D2E5F3901 /* AppViewController.swift in Sources */, + 7A1C0DE7C11B0A4D2E5F3903 /* ClipboardDetectPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/AppViewController.swift b/ios/App/App/AppViewController.swift new file mode 100644 index 0000000000..dae5028824 --- /dev/null +++ b/ios/App/App/AppViewController.swift @@ -0,0 +1,9 @@ +import Capacitor +import UIKit + +// App-local plugins aren't auto-discovered; register them once the bridge is up. +class AppViewController: CAPBridgeViewController { + override open func capacitorDidLoad() { + bridge?.registerPluginInstance(ClipboardDetectPlugin()) + } +} diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard index b44df7be8f..bf6ec4ce10 100644 --- a/ios/App/App/Base.lproj/Main.storyboard +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -11,7 +11,7 @@ - + diff --git a/ios/App/App/ClipboardDetectPlugin.swift b/ios/App/App/ClipboardDetectPlugin.swift new file mode 100644 index 0000000000..23ceab6b8f --- /dev/null +++ b/ios/App/App/ClipboardDetectPlugin.swift @@ -0,0 +1,22 @@ +import Capacitor +import UIKit + +/* + * Prompt-free clipboard presence check. UIPasteboard.hasStrings is metadata + * only, so it never raises the iOS 16+ "Allow Paste" alert — unlike a real + * Clipboard.read(), whose un-gestured use raced (and blocked) the camera + * permission dialog in the QR scanner. Content is still read exclusively via + * @capacitor/clipboard on a user gesture. + */ +@objc(ClipboardDetectPlugin) +public class ClipboardDetectPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "ClipboardDetectPlugin" + public let jsName = "ClipboardDetect" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "hasStrings", returnType: CAPPluginReturnPromise) + ] + + @objc func hasStrings(_ call: CAPPluginCall) { + call.resolve(["value": UIPasteboard.general.hasStrings]) + } +} diff --git a/src/components/Global/QRScanner/index.tsx b/src/components/Global/QRScanner/index.tsx index 6fd53abd0c..e7072e49ad 100644 --- a/src/components/Global/QRScanner/index.tsx +++ b/src/components/Global/QRScanner/index.tsx @@ -1,4 +1,5 @@ import { createPortal } from 'react-dom' +import { useEffect, useState } from 'react' import { Button } from '@/components/0_Bruddle/Button' import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' import { PEANUTMAN } from '@/assets/mascot' @@ -9,6 +10,10 @@ import { useQRScanner, type QRScanHandler } from './useQRScanner' import { useToast } from '@/components/0_Bruddle/Toast' import CameraPermissionModal from './CameraPermissionModal' import { Clipboard } from '@capacitor/clipboard' +import { clipboardHasStrings } from '@/utils/clipboard-detect' +import { extractPaymentValue } from '@/utils/clipboard-extract.utils' +import { isAndroidNative } from '@/utils/capacitor' +import { printableAddress } from '@/utils/general.utils' // ============================================================================ // Configuration @@ -89,7 +94,19 @@ function ScannerControls({ onClose, onToggleCamera }: { onClose: () => void; onT ) } -function ScanRegionOverlay({ onPaste }: { onPaste: () => void }) { +function ScanRegionOverlay({ + onPaste, + detectedAddress, + onUseDetected, + showPasteChip, + onUsePasteChip, +}: { + onPaste: () => void + detectedAddress: string | null + onUseDetected: () => void + showPasteChip: boolean + onUsePasteChip: () => void +}) { return (
{/* Darkened background with transparent scan region */} @@ -114,6 +131,23 @@ function ScanRegionOverlay({ onPaste }: { onPaste: () => void }) { Click to paste + {detectedAddress ? ( + + ) : showPasteChip ? ( + + ) : null}
) @@ -138,6 +172,58 @@ export default function QRScanner({ onScan, onClose, isOpen = true }: QRScannerP const { error, isPermissionDenied, isScanning, isCameraReady, videoRef, close, toggleCamera, retryCamera } = useQRScanner(onScan, onClose, isOpen) const toast = useToast() + const [detectedAddress, setDetectedAddress] = useState(null) + const [showPasteChip, setShowPasteChip] = useState(false) + + /* + * Platform-split clipboard shortcut. Android native: read at open and + * preview the copied EVM address — the read only trips the system paste + * toast, nothing blocking. iOS native: an un-gestured Clipboard.read() + * raises the "Allow Paste" alert, which raced the camera permission dialog + * and blocked it (PEANUT-UI-PYW) — so only a prompt-free hasStrings check + * runs here, and the actual read happens on the chip tap (a real gesture). + * Web/PWA: no pre-read at all; "Click to paste" remains. + */ + useEffect(() => { + if (!isScanning) { + setDetectedAddress(null) + setShowPasteChip(false) + return + } + let cancelled = false + if (isAndroidNative()) { + Clipboard.read() + .then(({ value }) => { + if (!cancelled) setDetectedAddress(extractPaymentValue((value ?? '').trim(), 'evmAddress')) + }) + .catch(() => { + if (!cancelled) setDetectedAddress(null) + }) + } else { + clipboardHasStrings().then((hasStrings) => { + if (!cancelled) setShowPasteChip(hasStrings) + }) + } + return () => { + cancelled = true + } + }, [isScanning]) + + const handleUsePasteChip = async () => { + try { + const { value } = await Clipboard.read() + const address = extractPaymentValue((value ?? '').trim(), 'evmAddress') + if (address) { + await onScan(address) + } else { + setShowPasteChip(false) + toast.error('Copied text is not a wallet address') + } + } catch { + setShowPasteChip(false) + toast.error('Could not access clipboard') + } + } const handlePaste = async () => { try { @@ -190,7 +276,13 @@ export default function QRScanner({ onScan, onClose, isOpen = true }: QRScannerP )} - + onScan(detectedAddress!)} + showPasteChip={showPasteChip} + onUsePasteChip={handleUsePasteChip} + /> )} , diff --git a/src/utils/__tests__/clipboard-detect.test.ts b/src/utils/__tests__/clipboard-detect.test.ts new file mode 100644 index 0000000000..3f7efb6c4d --- /dev/null +++ b/src/utils/__tests__/clipboard-detect.test.ts @@ -0,0 +1,44 @@ +// clipboardHasStrings gates the iOS QR-scanner paste chip: prompt-free on iOS +// native, and silently false everywhere the plugin can't answer (web, Android, +// older binaries running OTA'd JS). +import { clipboardHasStrings } from '../clipboard-detect' +import { isIOSNative } from '../capacitor' + +const hasStrings = jest.fn() + +jest.mock('@capacitor/core', () => ({ + registerPlugin: jest.fn(() => ({ hasStrings: () => hasStrings() })), +})) + +jest.mock('../capacitor', () => ({ + isIOSNative: jest.fn(() => false), +})) + +const mockIsIOSNative = isIOSNative as jest.MockedFunction + +describe('clipboardHasStrings', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns false off iOS without touching the plugin', async () => { + mockIsIOSNative.mockReturnValue(false) + await expect(clipboardHasStrings()).resolves.toBe(false) + expect(hasStrings).not.toHaveBeenCalled() + }) + + it('returns the native answer on iOS', async () => { + mockIsIOSNative.mockReturnValue(true) + hasStrings.mockResolvedValue({ value: true }) + await expect(clipboardHasStrings()).resolves.toBe(true) + + hasStrings.mockResolvedValue({ value: false }) + await expect(clipboardHasStrings()).resolves.toBe(false) + }) + + it('returns false when the plugin is unavailable (older binary)', async () => { + mockIsIOSNative.mockReturnValue(true) + hasStrings.mockRejectedValue(new Error('"ClipboardDetect" plugin is not implemented on ios')) + await expect(clipboardHasStrings()).resolves.toBe(false) + }) +}) diff --git a/src/utils/clipboard-detect.ts b/src/utils/clipboard-detect.ts new file mode 100644 index 0000000000..e7ece0984e --- /dev/null +++ b/src/utils/clipboard-detect.ts @@ -0,0 +1,27 @@ +import { registerPlugin } from '@capacitor/core' +import { isIOSNative } from './capacitor' + +interface ClipboardDetectPlugin { + hasStrings(): Promise<{ value: boolean }> +} + +// App-local iOS plugin (ios/App/App/ClipboardDetectPlugin.swift); no web/Android +// implementation exists — callers must treat "unavailable" as false. +const ClipboardDetect = registerPlugin('ClipboardDetect') + +/** + * iOS-native only: prompt-free "is there text on the clipboard?" check via + * UIPasteboard.hasStrings — metadata only, so it never raises the iOS 16+ + * "Allow Paste" alert the way an un-gestured Clipboard.read() does. Returns + * false on every other platform and on older binaries without the plugin + * (OTA'd JS), so the caller simply doesn't offer the paste shortcut there. + */ +export async function clipboardHasStrings(): Promise { + if (!isIOSNative()) return false + try { + const { value } = await ClipboardDetect.hasStrings() + return value + } catch { + return false + } +}