Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -22,6 +24,8 @@
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
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 = "<group>"; };
7A1C0DE7C11B0A4D2E5F3902 /* AppViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewController.swift; sourceTree = "<group>"; };
7A1C0DE7C11B0A4D2E5F3904 /* ClipboardDetectPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardDetectPlugin.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -156,6 +162,8 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
7A1C0DE7C11B0A4D2E5F3901 /* AppViewController.swift in Sources */,
7A1C0DE7C11B0A4D2E5F3903 /* ClipboardDetectPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
9 changes: 9 additions & 0 deletions ios/App/App/AppViewController.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
2 changes: 1 addition & 1 deletion ios/App/App/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="AppViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
Expand Down
22 changes: 22 additions & 0 deletions ios/App/App/ClipboardDetectPlugin.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
96 changes: 94 additions & 2 deletions src/components/Global/QRScanner/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<div className="fixed left-1/2 flex h-64 w-64 -translate-x-1/2 translate-y-1/2 justify-center">
{/* Darkened background with transparent scan region */}
Expand All @@ -114,6 +131,23 @@ function ScanRegionOverlay({ onPaste }: { onPaste: () => void }) {
<Icon name="paste" fill="white" height={16} width={16} />
<span className="text-sm">Click to paste</span>
</button>
{detectedAddress ? (
<button
onClick={onUseDetected}
className="mx-auto mt-3 flex items-center gap-1.5 rounded-full border border-white/40 px-3 py-1.5 text-white"
>
<Icon name="wallet" fill="white" height={16} width={16} />
<span className="text-sm font-semibold">{printableAddress(detectedAddress)}</span>
</button>
) : showPasteChip ? (
<button
onClick={onUsePasteChip}
className="mx-auto mt-3 flex items-center gap-1.5 rounded-full border border-white/40 px-3 py-1.5 text-white"
>
<Icon name="wallet" fill="white" height={16} width={16} />
<span className="text-sm font-semibold">Use copied address</span>
</button>
) : null}
</div>
</div>
)
Expand All @@ -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<string | null>(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 {
Expand Down Expand Up @@ -190,7 +276,13 @@ export default function QRScanner({ onScan, onClose, isOpen = true }: QRScannerP
</div>
)}
<ScannerControls onClose={close} onToggleCamera={toggleCamera} />
<ScanRegionOverlay onPaste={handlePaste} />
<ScanRegionOverlay
onPaste={handlePaste}
detectedAddress={detectedAddress}
onUseDetected={() => onScan(detectedAddress!)}
showPasteChip={showPasteChip}
onUsePasteChip={handleUsePasteChip}
/>
</>
)}
</div>,
Expand Down
44 changes: 44 additions & 0 deletions src/utils/__tests__/clipboard-detect.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof isIOSNative>

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)
})
})
27 changes: 27 additions & 0 deletions src/utils/clipboard-detect.ts
Original file line number Diff line number Diff line change
@@ -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<ClipboardDetectPlugin>('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<boolean> {
if (!isIOSNative()) return false
try {
const { value } = await ClipboardDetect.hasStrings()
return value
} catch {
return false
}
}
Loading