Merge branch 'master' into rn85

This commit is contained in:
Marcos Rodriguez 2026-04-29 14:02:08 -05:00
commit fc05826092
17 changed files with 635 additions and 322 deletions

View File

@ -4,7 +4,7 @@ import { Dimensions, LayoutAnimation, StyleSheet, Text, TouchableOpacity, View }
import { encodeUR } from '../blue_modules/ur';
import { BlueCurrentTheme } from '../components/themes';
import loc from '../loc';
import QRCodeComponent from './QRCodeComponent';
import QRCode from './QRCode';
import { BlueSpacing20 } from './BlueSpacing';
const { height, width } = Dimensions.get('window');
@ -165,7 +165,7 @@ export class DynamicQRCode extends Component<DynamicQRCodeProps, DynamicQRCodeSt
>
{this.state.displayQRCode && (
<View style={animatedQRCodeStyle.qrcodeContainer}>
<QRCodeComponent
<QRCode
isLogoRendered={false}
value={currentFragment.toUpperCase()}
size={this.state.qrCodeHeight}

337
components/QRCode.tsx Normal file
View File

@ -0,0 +1,337 @@
import Clipboard from '@react-native-clipboard/clipboard';
import { encodeQR } from 'qr';
import React, { useCallback, useMemo, useRef } from 'react';
import { Platform, StyleSheet, View, ViewStyle } from 'react-native';
import Share from 'react-native-share';
import Svg, { Defs, Image as SvgImage, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
import loc from '../loc';
import { ActionIcons } from '../typings/ActionIcons';
import ToolTipMenu from './TooltipMenu';
import { Action } from './types';
type ErrorCorrectionLevel = 'H' | 'Q' | 'M' | 'L';
interface QRCodeProps {
value: string;
size?: number;
isLogoRendered?: boolean;
isMenuAvailable?: boolean;
logoSize?: number;
ecl?: ErrorCorrectionLevel;
onError?: (error?: unknown) => void;
}
const GRADIENT_ID = 'qrgrad';
const GRADIENT_STOP_1 = '#0c2550';
const GRADIENT_STOP_2 = '#1e3a8a';
const BACKGROUND = '#FFFFFF';
const LOGO_BACKGROUND = '#FFFFFF';
const eclMap: Record<ErrorCorrectionLevel, 'low' | 'medium' | 'quartile' | 'high'> = {
L: 'low',
M: 'medium',
Q: 'quartile',
H: 'high',
};
const actionIcons: { [key: string]: ActionIcons } = {
Copy: { iconValue: 'doc.on.doc' },
Share: { iconValue: 'square.and.arrow.up' },
};
const actionKeys = { Copy: 'copy', Share: 'share' };
// Copy-as-image is iOS/macOS-only — @react-native-clipboard/clipboard's setImage
// is not implemented on Android. Android users still get text-copy via the
// dedicated CopyTextToClipboard control rendered next to the QR on every screen.
const menuActions: Action[] =
Platform.OS === 'ios' || Platform.OS === 'macos'
? [
{ id: actionKeys.Copy, text: loc.transactions.details_copy, icon: actionIcons.Copy },
{ id: actionKeys.Share, text: loc.receive.details_share, icon: actionIcons.Share },
]
: [{ id: actionKeys.Share, text: loc.receive.details_share, icon: actionIcons.Share }];
const roundUpToOdd = (n: number): number => {
const rounded = Math.ceil(n);
return rounded % 2 === 0 ? rounded + 1 : rounded;
};
const MATRIX_CACHE_MAX = 128;
const matrixCache = new Map<string, boolean[][]>();
const getCachedMatrix = (value: string, ecl: ErrorCorrectionLevel): boolean[][] => {
const key = `${ecl}|${value}`;
const hit = matrixCache.get(key);
if (hit) {
matrixCache.delete(key);
matrixCache.set(key, hit);
return hit;
}
const m = encodeQR(value, 'raw', { ecc: eclMap[ecl], border: 0 });
matrixCache.set(key, m);
if (matrixCache.size > MATRIX_CACHE_MAX) {
const first = matrixCache.keys().next().value;
if (first !== undefined) matrixCache.delete(first);
}
return m;
};
type RenderPlan = {
N: number;
cell: number;
dataPath: string;
finderOrigins: Array<[number, number]>;
logoCells: number;
logoStart: number;
};
const PLAN_CACHE_MAX = 64;
const planCache = new Map<string, RenderPlan>();
const getCachedPlan = (value: string, ecl: ErrorCorrectionLevel, size: number, isLogoRendered: boolean, logoSize: number): RenderPlan => {
const key = `${ecl}|${size}|${isLogoRendered ? 'L' + logoSize : 'NL'}|${value}`;
const hit = planCache.get(key);
if (hit) {
planCache.delete(key);
planCache.set(key, hit);
return hit;
}
const matrix = getCachedMatrix(value, ecl);
const N = matrix.length;
const cell = size / (N + 2);
let logoCells = 0;
let logoStart = 0;
if (isLogoRendered) {
const desired = (logoSize + cell) / cell;
logoCells = Math.min(roundUpToOdd(desired), N);
logoStart = Math.floor((N - logoCells) / 2);
}
const logoEnd = logoStart + logoCells;
const finderOrigins: Array<[number, number]> =
N >= 7
? [
[0, 0],
[0, N - 7],
[N - 7, 0],
]
: [];
const isInsideFinder = (r: number, c: number): boolean =>
finderOrigins.some(([fr, fc]) => r >= fr && r < fr + 7 && c >= fc && c < fc + 7);
let dataPath = '';
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
if (!matrix[r][c]) continue;
if (isLogoRendered && r >= logoStart && r < logoEnd && c >= logoStart && c < logoEnd) continue;
if (isInsideFinder(r, c)) continue;
dataPath += `M${(c + 1) * cell} ${(r + 1) * cell}h${cell}v${cell}h-${cell}z`;
}
}
const plan: RenderPlan = { N, cell, dataPath, finderOrigins, logoCells, logoStart };
planCache.set(key, plan);
if (planCache.size > PLAN_CACHE_MAX) {
const first = planCache.keys().next().value;
if (first !== undefined) planCache.delete(first);
}
return plan;
};
const QRCode: React.FC<QRCodeProps> = ({
value = '',
size = 300,
isLogoRendered = true,
isMenuAvailable = true,
logoSize = 90,
ecl = 'H',
onError,
}) => {
const svgRef = useRef<Svg>(null);
const plan = useMemo<RenderPlan | null>(() => {
try {
return getCachedPlan(value, ecl, size, isLogoRendered, logoSize);
} catch (e) {
onError?.(e);
return null;
}
}, [value, ecl, size, isLogoRendered, logoSize, onError]);
const handleCopy = useCallback(() => {
if (!svgRef.current) return;
svgRef.current.toDataURL((data: string) => {
if (data) Clipboard.setImage(data);
});
}, []);
const handleShare = useCallback(() => {
if (!svgRef.current) return;
svgRef.current.toDataURL((data: string) => {
if (!data) {
console.warn('QRCode: toDataURL returned empty data');
return;
}
const cleaned = data.replace(/(\r\n|\n|\r)/gm, '');
Share.open({
url: `data:image/png;base64,${cleaned}`,
type: 'image/png',
filename: 'qrcode',
failOnCancel: false,
// Workaround for Android FileProvider crash with data: URLs since react-native-share@12.1.1.
// Accepted at runtime but missing from ShareOptions types as of 12.2.6.
// See https://github.com/react-native-share/react-native-share/issues/1683
// @ts-expect-error - useInternalStorage missing from ShareOptions type
useInternalStorage: true,
}).catch((error: Error) => console.warn('QRCode share failed:', error));
});
}, []);
const onPressMenuItem = useCallback(
(id: string) => {
if (id === actionKeys.Copy) handleCopy();
else if (id === actionKeys.Share) handleShare();
},
[handleCopy, handleShare],
);
const stylesHook = StyleSheet.create({
placeholder: { width: size, height: size, backgroundColor: BACKGROUND },
});
const qrButtonStyle: ViewStyle = {
width: size,
height: size,
justifyContent: 'center',
alignItems: 'center',
};
const renderQR = useMemo(() => {
if (!plan) return null;
const { cell, dataPath, finderOrigins, logoCells, logoStart } = plan;
const gradFill = `url(#${GRADIENT_ID})`;
const finderShapes: React.ReactElement[] = [];
const outerR = 2 * cell;
const holeR = 1.25 * cell;
const dotR = 0.9 * cell;
finderOrigins.forEach(([fr, fc], i) => {
const x = (fc + 1) * cell;
const y = (fr + 1) * cell;
finderShapes.push(
<Rect
key={`finder-frame-${i}`}
testID="qr-finder-frame"
x={x}
y={y}
width={7 * cell}
height={7 * cell}
rx={outerR}
ry={outerR}
fill={gradFill}
/>,
<Rect
key={`finder-hole-${i}`}
testID="qr-finder-hole"
x={x + cell}
y={y + cell}
width={5 * cell}
height={5 * cell}
rx={holeR}
ry={holeR}
fill={BACKGROUND}
/>,
<Rect
key={`finder-dot-${i}`}
testID="qr-finder-dot"
x={x + 2 * cell}
y={y + 2 * cell}
width={3 * cell}
height={3 * cell}
rx={dotR}
ry={dotR}
fill={gradFill}
/>,
);
});
const backdropX = (logoStart + 1) * cell;
const backdropY = (logoStart + 1) * cell;
const backdropSize = logoCells * cell;
const logoCenter = size / 2;
return (
<Svg ref={svgRef} testID="BitcoinAddressQRCode" width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Defs>
<LinearGradient id={GRADIENT_ID} gradientUnits="userSpaceOnUse" x1={0} y1={0} x2={size} y2={size}>
<Stop offset="0" stopColor={GRADIENT_STOP_1} />
<Stop offset="1" stopColor={GRADIENT_STOP_2} />
</LinearGradient>
</Defs>
<Rect testID="qr-background" x={0} y={0} width={size} height={size} fill={BACKGROUND} />
{dataPath ? <Path testID="qr-cells-path" d={dataPath} fill={gradFill} /> : null}
{finderShapes}
{isLogoRendered && logoCells > 0 && (
<>
<Rect
testID="qr-logo-backdrop"
x={backdropX}
y={backdropY}
width={backdropSize}
height={backdropSize}
rx={cell * 0.5}
ry={cell * 0.5}
fill={LOGO_BACKGROUND}
/>
<SvgImage
testID="qr-logo-image"
href={require('../img/qr-code.png')}
x={logoCenter - logoSize / 2}
y={logoCenter - logoSize / 2}
width={logoSize}
height={logoSize}
preserveAspectRatio="xMidYMid meet"
/>
</>
)}
</Svg>
);
}, [plan, size, isLogoRendered, logoSize]);
const content = renderQR ?? <View testID="qr-placeholder" style={stylesHook.placeholder} />;
return (
<View
style={styles.container}
accessibilityIgnoresInvertColors
importantForAccessibility="no-hide-descendants"
accessibilityRole="image"
accessibilityLabel={loc.receive.qrcode_for_the_address}
>
{isMenuAvailable ? (
<ToolTipMenu
actions={menuActions}
onPressMenuItem={onPressMenuItem}
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={qrButtonStyle}
>
{content}
</ToolTipMenu>
) : (
content
)}
</View>
);
};
export default QRCode;
const styles = StyleSheet.create({
container: { alignItems: 'center', justifyContent: 'center' },
});

View File

@ -1,146 +0,0 @@
import Clipboard from '@react-native-clipboard/clipboard';
import React, { useCallback, useRef } from 'react';
import { Platform, StyleSheet, View, ViewStyle } from 'react-native';
import QRCode from 'react-native-qrcode-svg';
import Share from 'react-native-share';
import loc from '../loc';
import { ActionIcons } from '../typings/ActionIcons';
import { useTheme } from './themes';
import ToolTipMenu from './TooltipMenu';
import { Action } from './types';
interface QRCodeComponentProps {
value: string;
isLogoRendered?: boolean;
isMenuAvailable?: boolean;
logoSize?: number;
size?: number;
ecl?: 'H' | 'Q' | 'M' | 'L';
onError?: () => void;
}
const BORDER_WIDTH = 6;
const actionIcons: { [key: string]: ActionIcons } = {
Share: {
iconValue: 'square.and.arrow.up',
},
Copy: {
iconValue: 'doc.on.doc',
},
};
const actionKeys = {
Share: 'share',
Copy: 'copy',
};
const menuActions: Action[] =
Platform.OS === 'ios' || Platform.OS === 'macos'
? [
{
id: actionKeys.Copy,
text: loc.transactions.details_copy,
icon: actionIcons.Copy,
},
{ id: actionKeys.Share, text: loc.receive.details_share, icon: actionIcons.Share },
]
: [
{
id: actionKeys.Copy,
text: loc.transactions.details_copy,
icon: actionIcons.Copy,
},
];
const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
value = '',
isLogoRendered = true,
isMenuAvailable = true,
logoSize = 90,
size = 300,
ecl = 'H',
onError = () => {},
}) => {
const qrCode = useRef<any>(null);
const { colors, dark } = useTheme();
const handleShareQRCode = () => {
qrCode.current.toDataURL((data: string) => {
data = data.replace(/(\r\n|\n|\r)/gm, '');
const shareImageBase64 = {
url: `data:image/png;base64,${data}`,
};
Share.open(shareImageBase64).catch((error: Error) => console.log(error));
});
};
const onPressMenuItem = useCallback((id: string) => {
if (id === actionKeys.Share) {
handleShareQRCode();
} else if (id === actionKeys.Copy) {
qrCode.current.toDataURL(Clipboard.setImage);
}
}, []);
// Adjust the size of the QR code to account for the border width
const newSize = dark ? size - BORDER_WIDTH * 2 : size;
const stylesHook = StyleSheet.create({
container: { borderWidth: dark ? BORDER_WIDTH : 0 },
});
const qrButtonStyle: ViewStyle = {
width: newSize,
height: newSize,
justifyContent: 'center',
alignItems: 'center',
};
const renderQRCode = (
<QRCode
value={value}
{...(isLogoRendered ? { logo: require('../img/qr-code.png') } : {})}
size={newSize}
logoSize={logoSize}
color="#000000"
logoBackgroundColor={colors.brandingColor}
backgroundColor="#FFFFFF"
ecl={ecl}
getRef={(c: any) => (qrCode.current = c)}
onError={onError}
testID="BitcoinAddressQRCode"
/>
);
return (
<View
style={[styles.container, stylesHook.container]}
accessibilityIgnoresInvertColors
importantForAccessibility="no-hide-descendants"
accessibilityRole="image"
accessibilityLabel={loc.receive.qrcode_for_the_address}
>
{isMenuAvailable ? (
<ToolTipMenu
actions={menuActions}
onPressMenuItem={onPressMenuItem}
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={qrButtonStyle}
>
{renderQRCode}
</ToolTipMenu>
) : (
renderQRCode
)}
</View>
);
};
export default QRCodeComponent;
const styles = StyleSheet.create({
container: { borderColor: '#FFFFFF', alignItems: 'center', justifyContent: 'center' },
});

View File

@ -10,6 +10,7 @@ module.exports = {
moduleNameMapper: {
'^expo/fetch$': '<rootDir>/util/expo-fetch-nodejs.js',
'^@react-native-vector-icons/(.*)$': '<rootDir>/tests/mocks/vector-icons.js',
'^react-native-svg$': '<rootDir>/tests/mocks/react-native-svg.js',
},
setupFiles: ['./tests/setup.js'],
watchPathIgnorePatterns: ['<rootDir>/node_modules'],

166
package-lock.json generated
View File

@ -68,6 +68,7 @@
"pako": "file:blue_modules/pako",
"payjoin-client": "1.0.1",
"prop-types": "15.8.1",
"qr": "0.5.5",
"react": "19.2.3",
"react-localization": "github:BlueWallet/react-localization#ae7969a",
"react-native": "0.85.2",
@ -93,7 +94,6 @@
"react-native-notifications": "5.2.2",
"react-native-permissions": "5.5.1",
"react-native-prompt-android": "github:BlueWallet/react-native-prompt-android#ed168d66fed556bc2ed07cf498770f058b78a376",
"react-native-qrcode-svg": "6.3.21",
"react-native-quick-actions": "0.3.13",
"react-native-reanimated": "4.3.0",
"react-native-safe-area-context": "5.7.0",
@ -110,6 +110,7 @@
"silent-payments": "github:BlueWallet/SilentPayments#59a037",
"slip39": "github:BlueWallet/slip39-js#d316ee6",
"stream-browserify": "3.0.0",
"text-encoding": "0.7.0",
"url": "0.11.4",
"wif": "2.0.6"
},
@ -8139,10 +8140,6 @@
"randombytes": "^2.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"license": "MIT"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"dev": true,
@ -15464,13 +15461,6 @@
"node": ">=10.4.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"license": "MIT",
@ -15654,136 +15644,13 @@
"bitcoin-ops": "^1.3.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"node_modules/qr": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/qr/-/qr-0.5.5.tgz",
"integrity": "sha512-iQBvKj7MRKO+co+MY0IZpyLO+ezvttxsmV86WywrgPuAmgBkv0pytyi03wourniSoPgzffeBW6cBgIkpqcvjTg==",
"license": "(MIT OR Apache-2.0)",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/decamelize": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
"node": ">= 20.19.0"
}
},
"node_modules/qs": {
@ -16295,20 +16162,6 @@
"integrity": "sha512-vTyVQW/EjOYxKdXmq/MWE97MNuQzoqSzvlsIBFMQV9dVH3X9+zPfR7osPO6EM8ATASrcntMSepKyKd7W6DwHPA==",
"license": "MIT"
},
"node_modules/react-native-qrcode-svg": {
"version": "6.3.21",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.0",
"qrcode": "^1.5.4",
"text-encoding": "^0.7.0"
},
"peerDependencies": {
"react": "*",
"react-native": ">=0.63.4",
"react-native-svg": ">=14.0.0"
}
},
"node_modules/react-native-quick-actions": {
"version": "0.3.13",
"license": "MIT"
@ -18085,6 +17938,9 @@
},
"node_modules/text-encoding": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
"integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
"deprecated": "no longer maintained",
"license": "(Unlicense OR Apache-2.0)"
},
"node_modules/text-hex": {

View File

@ -151,6 +151,7 @@
"pako": "file:blue_modules/pako",
"payjoin-client": "1.0.1",
"prop-types": "15.8.1",
"qr": "0.5.5",
"react": "19.2.3",
"react-localization": "github:BlueWallet/react-localization#ae7969a",
"react-native": "0.85.2",
@ -176,7 +177,6 @@
"react-native-notifications": "5.2.2",
"react-native-permissions": "5.5.1",
"react-native-prompt-android": "github:BlueWallet/react-native-prompt-android#ed168d66fed556bc2ed07cf498770f058b78a376",
"react-native-qrcode-svg": "6.3.21",
"react-native-quick-actions": "0.3.13",
"react-native-reanimated": "4.3.0",
"react-native-safe-area-context": "5.7.0",
@ -193,6 +193,7 @@
"silent-payments": "github:BlueWallet/SilentPayments#59a037",
"slip39": "github:BlueWallet/slip39-js#d316ee6",
"stream-browserify": "3.0.0",
"text-encoding": "0.7.0",
"url": "0.11.4",
"wif": "2.0.6"
}

View File

@ -4,7 +4,7 @@ import { StyleSheet, View } from 'react-native';
import { BlueTextCentered } from '../../BlueComponents';
import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import SafeArea from '../../components/SafeArea';
import { useTheme } from '../../components/themes';
import loc from '../../loc';
@ -29,7 +29,7 @@ const LNDViewAdditionalInvoicePreImage = () => {
<BlueTextCentered>{loc.lndViewInvoice.preimage}:</BlueTextCentered>
<BlueSpacing20 />
<View style={styles.qrCodeContainer}>
<QRCodeComponent value={preImageData} size={300} logoSize={90} />
<QRCode value={preImageData} size={300} logoSize={90} />
</View>
<BlueSpacing20 />
<CopyTextToClipboard text={preImageData} />

View File

@ -7,7 +7,7 @@ import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/h
import { BlueText, BlueTextCentered } from '../../BlueComponents';
import Button from '../../components/Button';
import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import { useTheme } from '../../components/themes';
import loc from '../../loc';
import { BitcoinUnit } from '../../models/bitcoinUnits';
@ -252,7 +252,7 @@ const LNDViewInvoice = () => {
<ScrollView>
<View style={[styles.activeRoot, stylesHook.root]}>
<View style={styles.activeQrcode}>
<QRCodeComponent value={invoice.payment_request} size={qrCodeSize} />
<QRCode value={invoice.payment_request} size={qrCodeSize} />
</View>
<BlueSpacing20 />
<BlueText>
@ -275,7 +275,7 @@ const LNDViewInvoice = () => {
return (
<View style={[styles.activeRoot, stylesHook.root]}>
<View style={styles.activeQrcode}>
<QRCodeComponent value={invoice} size={qrCodeSize} />
<QRCode value={invoice} size={qrCodeSize} />
</View>
</View>
);

View File

@ -15,7 +15,7 @@ import Button from '../../components/Button';
import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import HandOffComponent from '../../components/HandOffComponent';
import HeaderMenuButton from '../../components/HeaderMenuButton';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import SegmentedControl from '../../components/SegmentedControl';
import { useTheme } from '../../components/themes';
import TipBox from '../../components/TipBox';
@ -382,7 +382,7 @@ const ReceiveDetails = () => {
</>
)}
<View style={styles.qrCodeContainer}>
<QRCodeComponent value={bip21encoded} size={qrCodeSize} />
<QRCode value={bip21encoded} size={qrCodeSize} />
</View>
<CopyTextToClipboard text={isCustom ? bip21encoded : address} isAddress={true} />
</View>
@ -399,7 +399,7 @@ const ReceiveDetails = () => {
<>
<TipBox description={loc.receive.bip47_explanation} containerStyle={styles.tip} />
<View style={styles.qrCodeContainer}>
<QRCodeComponent value={qrValue} size={qrCodeSize} />
<QRCode value={qrValue} size={qrCodeSize} />
</View>
<CopyTextToClipboard text={qrValue} truncated={false} />
</>

View File

@ -6,7 +6,7 @@ import { RouteProp, useRoute } from '@react-navigation/native';
import { BlueSpacing20 } from '../../components/BlueSpacing';
import { BlueTextCentered } from '../../BlueComponents';
import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import { useTheme } from '../../components/themes';
import loc from '../../loc';
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
@ -26,7 +26,7 @@ const ViewEditMultisigShareCosignerSheet = () => {
</BlueTextCentered>
<BlueSpacing20 />
<View style={styles.qrContainer}>
<QRCodeComponent value={cosignerXpubURv2} size={260} />
<QRCode value={cosignerXpubURv2} size={260} />
</View>
<BlueSpacing20 />
<CopyTextToClipboard text={cosignerXpub} truncated={false} />

View File

@ -10,7 +10,7 @@ import { BlueText } from '../../BlueComponents';
import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet';
import { WatchOnlyWallet } from '../../class/wallets/watch-only-wallet';
import HandOffComponent from '../../components/HandOffComponent';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import SeedWords from '../../components/SeedWords';
import { useTheme } from '../../components/themes';
import { HandOffActivityType } from '../../components/types';
@ -195,7 +195,7 @@ const WalletExport: React.FC = () => {
<BlueText style={styles.scanText}>{loc.wallets.scan_import}</BlueText>
<View style={styles.qrCodeContainer}>
<QRCodeComponent isMenuAvailable={false} value={secret} size={qrCodeSize} logoSize={70} />
<QRCode isMenuAvailable={false} value={secret} size={qrCodeSize} logoSize={70} />
</View>
{/* Do not allow to copy mnemonic */}

View File

@ -7,7 +7,7 @@ import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { BlueSpacing20 } from '../../components/BlueSpacing';
import { BlueTextCentered } from '../../BlueComponents';
import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import Button from '../../components/Button';
import { useTheme } from '../../components/themes';
import loc from '../../loc';
@ -29,7 +29,7 @@ const WalletsAddMultisigCosignerXpubSheet = () => {
</BlueTextCentered>
<BlueSpacing20 />
<View style={styles.qrContainer}>
<QRCodeComponent value={cosignerXpubURv2} size={260} />
<QRCode value={cosignerXpubURv2} size={260} />
</View>
<BlueSpacing20 />
<CopyTextToClipboard text={cosignerXpub} truncated={false} />

View File

@ -4,7 +4,7 @@ import { BackHandler, LayoutChangeEvent, StyleSheet, View } from 'react-native';
import { BlueTextCentered } from '../../BlueComponents';
import Button from '../../components/Button';
import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
import { useTheme } from '../../components/themes';
import loc from '../../loc';
@ -69,7 +69,7 @@ const PleaseBackupLNDHub = () => {
<BlueSpacing20 />
</View>
<BlueSpacing20 />
<QRCodeComponent value={wallet.getSecret()} size={qrCodeSize} />
<QRCode value={wallet.getSecret()} size={qrCodeSize} />
<CopyTextToClipboard text={wallet.getSecret()} />
<BlueSpacing20 />
<Button onPress={dismiss} title={loc.pleasebackup.ok_lnd} />

View File

@ -6,7 +6,7 @@ import { BlueText } from '../../BlueComponents';
import Button from '../../components/Button';
import CopyTextToClipboard from '../../components/CopyTextToClipboard';
import HandOffComponent from '../../components/HandOffComponent';
import QRCodeComponent from '../../components/QRCodeComponent';
import QRCode from '../../components/QRCode';
import SafeArea from '../../components/SafeArea';
import { useScreenProtect } from '../../hooks/useScreenProtect';
import loc from '../../loc';
@ -118,7 +118,7 @@ const WalletXpub: React.FC = () => {
<BlueSpacing20 />
</>
)}
<QRCodeComponent value={xPubText || xpub} size={qrCodeSize} />
<QRCode value={xPubText || xpub} size={qrCodeSize} />
{xPubText && <CopyTextToClipboard text={xPubText} />}
</View>

57
tests/mocks/react-native-svg.js vendored Normal file
View File

@ -0,0 +1,57 @@
/* eslint-disable react/prop-types */
const React = require('react');
const { View } = require('react-native');
function makeMock(name) {
const Component = ({ children, testID, ...rest }) => React.createElement(View, { ...rest, testID: testID ?? 'svg-' + name }, children);
Component.displayName = name;
return Component;
}
const Svg = makeMock('Svg');
const Rect = makeMock('Rect');
const Circle = makeMock('Circle');
const Defs = makeMock('Defs');
const LinearGradient = makeMock('LinearGradient');
const RadialGradient = makeMock('RadialGradient');
const Stop = makeMock('Stop');
const G = makeMock('G');
const SvgImage = makeMock('Image');
const Path = makeMock('Path');
const ClipPath = makeMock('ClipPath');
const Mask = makeMock('Mask');
const Polygon = makeMock('Polygon');
const Polyline = makeMock('Polyline');
const Line = makeMock('Line');
const Ellipse = makeMock('Ellipse');
const Text = makeMock('Text');
const TSpan = makeMock('TSpan');
const Use = makeMock('Use');
const Symbol = makeMock('Symbol');
const Pattern = makeMock('Pattern');
module.exports = {
__esModule: true,
default: Svg,
Svg,
Rect,
Circle,
Defs,
LinearGradient,
RadialGradient,
Stop,
G,
Image: SvgImage,
Path,
ClipPath,
Mask,
Polygon,
Polyline,
Line,
Ellipse,
Text,
TSpan,
Use,
Symbol,
Pattern,
};

View File

@ -35,6 +35,22 @@ global.fetch = require('node-fetch');
jest.mock('@react-native-clipboard/clipboard', () => mockClipboard);
// Workaround for software-mansion/react-native-reanimated#8806.
// Fixed upstream in reanimated 4.3.0; remove once we upgrade.
// Path is held in a variable so tsc does not statically resolve into worklets'
// src/*.ts (which has its own type errors) under allowJs.
const workletsMockPath = 'react-native-worklets/src/mock';
jest.mock('react-native-worklets', () => require(workletsMockPath));
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
jest.mock('react-native-capture-protection', () => ({
CaptureProtection: {
prevent: jest.fn(),
allow: jest.fn(),
isScreenRecording: jest.fn(() => Promise.resolve(false)),
},
}));
jest.mock('react-native-watch-connectivity', () => {
return {
getIsWatchAppInstalled: jest.fn(() => Promise.resolve(false)),

191
tests/unit/QRCode.test.tsx Normal file
View File

@ -0,0 +1,191 @@
import React from 'react';
import { configure, render } from '@testing-library/react-native';
import QRCode from '../../components/QRCode';
configure({ defaultIncludeHiddenElements: true });
const mockEncodeQR = jest.fn();
jest.mock('qr', () => ({
__esModule: true,
encodeQR: (...args: unknown[]) => mockEncodeQR(...args),
default: (...args: unknown[]) => mockEncodeQR(...args),
}));
jest.mock('../../components/TooltipMenu', () => {
const ReactLocal = jest.requireActual('react');
return {
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => ReactLocal.createElement(ReactLocal.Fragment, null, children),
};
});
const makeMatrix = (n: number, fill: boolean | ((r: number, c: number) => boolean)): boolean[][] => {
const m: boolean[][] = [];
for (let r = 0; r < n; r++) {
const row: boolean[] = [];
for (let c = 0; c < n; c++) {
row.push(typeof fill === 'function' ? fill(r, c) : fill);
}
m.push(row);
}
return m;
};
type ParsedCell = { x: number; y: number; w: number; h: number };
const parseDataPath = (d: string): ParsedCell[] => {
const re = /M(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)h(-?\d+(?:\.\d+)?)v(-?\d+(?:\.\d+)?)h-(-?\d+(?:\.\d+)?)z/g;
const out: ParsedCell[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(d)) !== null) {
out.push({ x: parseFloat(m[1]), y: parseFloat(m[2]), w: parseFloat(m[3]), h: parseFloat(m[4]) });
}
return out;
};
let testCounter = 0;
const uniqueValue = () => `test-value-${++testCounter}-${Math.random()}`;
describe('QRCode', () => {
beforeEach(() => {
mockEncodeQR.mockReset();
});
it('renders without crashing for a typical value', () => {
mockEncodeQR.mockReturnValue(makeMatrix(21, (r, c) => (r + c) % 2 === 0));
const tree = render(<QRCode value={uniqueValue()} size={200} isLogoRendered={false} isMenuAvailable={false} />);
expect(tree.toJSON()).not.toBeNull();
});
it('renders root Svg sized to the size prop', () => {
mockEncodeQR.mockReturnValue(makeMatrix(21, false));
const { getByTestId } = render(<QRCode value={uniqueValue()} size={240} isLogoRendered={false} isMenuAvailable={false} />);
const svg = getByTestId('BitcoinAddressQRCode');
expect(svg.props.width).toBe(240);
expect(svg.props.height).toBe(240);
});
it('renders exactly one LinearGradient with id=qrgrad', () => {
mockEncodeQR.mockReturnValue(makeMatrix(21, true));
const { getAllByTestId } = render(<QRCode value={uniqueValue()} size={200} isLogoRendered={false} isMenuAvailable={false} />);
const gradients = getAllByTestId('svg-LinearGradient');
expect(gradients).toHaveLength(1);
expect(gradients[0].props.id).toBe('qrgrad');
});
it('emits data cells in a single Path with a subpath per dark cell', () => {
const matrix: boolean[][] = [
[true, false, true],
[false, true, false],
[true, false, true],
];
mockEncodeQR.mockReturnValue(matrix);
const size = 100;
const N = matrix.length;
const expectedCell = size / (N + 2); // 1-cell quiet zone on each side
const { getByTestId } = render(<QRCode value={uniqueValue()} size={size} isLogoRendered={false} isMenuAvailable={false} />);
const path = getByTestId('qr-cells-path');
expect(path.props.fill).toBe('url(#qrgrad)');
const cells = parseDataPath(path.props.d);
expect(cells).toHaveLength(5);
cells.forEach(cell => expect(cell.w).toBeCloseTo(expectedCell));
// First dark cell is matrix (0,0), which renders at SVG (cell, cell) due to quiet zone.
expect(cells[0].x).toBeCloseTo(expectedCell);
expect(cells[0].y).toBeCloseTo(expectedCell);
});
it('renders a grid-aligned logo backdrop and skips cells under the logo', () => {
const N = 33;
mockEncodeQR.mockReturnValue(makeMatrix(N, true));
const size = 350;
const logoSize = 90;
const cell = size / (N + 2);
const { getByTestId } = render(
<QRCode value={uniqueValue()} size={size} logoSize={logoSize} isLogoRendered={true} isMenuAvailable={false} />,
);
const backdrop = getByTestId('qr-logo-backdrop');
const { x, y, width, height } = backdrop.props;
const epsilon = 1e-6;
const isMultipleOfCell = (v: number) => Math.abs(v / cell - Math.round(v / cell)) < epsilon;
expect(isMultipleOfCell(x)).toBe(true);
expect(isMultipleOfCell(y)).toBe(true);
expect(isMultipleOfCell(width)).toBe(true);
expect(isMultipleOfCell(height)).toBe(true);
expect(width).toBe(height);
const path = getByTestId('qr-cells-path');
const cells = parseDataPath(path.props.d);
const backdropRight = x + width;
const backdropBottom = y + height;
const anyInside = cells.some(c => {
const cx = c.x + c.w / 2;
const cy = c.y + c.h / 2;
return cx > x && cx < backdropRight && cy > y && cy < backdropBottom;
});
expect(anyInside).toBe(false);
});
it('does not render the logo image when isLogoRendered is false', () => {
mockEncodeQR.mockReturnValue(makeMatrix(21, false));
const { queryByTestId } = render(<QRCode value={uniqueValue()} size={200} isLogoRendered={false} isMenuAvailable={false} />);
expect(queryByTestId('qr-logo-image')).toBeNull();
expect(queryByTestId('qr-logo-backdrop')).toBeNull();
});
it('renders 3 finder patterns (frame + hole + dot) when matrix is >= 7x7', () => {
mockEncodeQR.mockReturnValue(makeMatrix(21, true));
const { getAllByTestId } = render(<QRCode value={uniqueValue()} size={210} isLogoRendered={false} isMenuAvailable={false} />);
expect(getAllByTestId('qr-finder-frame')).toHaveLength(3);
expect(getAllByTestId('qr-finder-hole')).toHaveLength(3);
expect(getAllByTestId('qr-finder-dot')).toHaveLength(3);
});
it('does not emit data cells inside finder-pattern regions', () => {
const N = 21;
const size = 230;
const cell = size / (N + 2);
mockEncodeQR.mockReturnValue(makeMatrix(N, true));
const { getByTestId } = render(<QRCode value={uniqueValue()} size={size} isLogoRendered={false} isMenuAvailable={false} />);
const cells = parseDataPath(getByTestId('qr-cells-path').props.d);
const finderOrigins: Array<[number, number]> = [
[0, 0],
[0, N - 7],
[N - 7, 0],
];
const epsilon = 1e-6;
const anyInsideFinder = cells.some(c => {
// Data cells are shifted by 1 cell (quiet zone); convert SVG coords back to matrix coords.
const col = Math.round(c.x / cell + epsilon) - 1;
const row = Math.round(c.y / cell + epsilon) - 1;
return finderOrigins.some(([fr, fc]) => row >= fr && row < fr + 7 && col >= fc && col < fc + 7);
});
expect(anyInsideFinder).toBe(false);
});
it('reuses cached matrix across renders for the same value (encodeQR called once)', () => {
mockEncodeQR.mockReturnValue(makeMatrix(21, true));
const val = uniqueValue();
const { rerender } = render(<QRCode value={val} size={200} isLogoRendered={false} isMenuAvailable={false} />);
rerender(<QRCode value={val} size={200} isLogoRendered={false} isMenuAvailable={false} />);
rerender(<QRCode value={val} size={200} isLogoRendered={false} isMenuAvailable={false} />);
expect(mockEncodeQR).toHaveBeenCalledTimes(1);
});
it('calls onError and renders placeholder when encodeQR throws', () => {
mockEncodeQR.mockImplementation(() => {
throw new Error('bad input');
});
const onError = jest.fn();
const { queryByTestId, getByTestId } = render(
<QRCode value={uniqueValue()} size={150} onError={onError} isMenuAvailable={false} isLogoRendered={false} />,
);
expect(onError).toHaveBeenCalledTimes(1);
expect(queryByTestId('BitcoinAddressQRCode')).toBeNull();
const placeholder = getByTestId('qr-placeholder');
expect(placeholder.props.style).toEqual(expect.objectContaining({ width: 150, height: 150 }));
});
});