Merge branch 'master' into rn85
This commit is contained in:
commit
fc05826092
@ -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
337
components/QRCode.tsx
Normal 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' },
|
||||
});
|
||||
@ -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' },
|
||||
});
|
||||
@ -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
166
package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
57
tests/mocks/react-native-svg.js
vendored
Normal 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,
|
||||
};
|
||||
@ -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
191
tests/unit/QRCode.test.tsx
Normal 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 }));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user