Add scan progress indicator, payment card detection, and production cleanup

Features:
- Add scanning progress indicator showing current detection step
- Detect payment cards (Visa, Mastercard, Amex, etc.) via PPSE/EMV AIDs
- Show "Payment Device Detected" instead of "Implant Detected" for payment cards
- Hide compatible implants section for payment devices, show conversion instead
- Add Spark 1/2 implant detection via vivokey.co NDEF URL
- Hide conversion card when actual implant is detected

Technical:
- Add DetectionProgressCallback for real-time detection status
- Add payment network AIDs to commands.ts
- Configure babel to strip console.log/warn in production builds
- Add logger utility for development-only logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-01-23 09:29:17 -08:00
parent 7596e4cbfa
commit 63d815c1c2
15 changed files with 1179 additions and 130 deletions

View File

@@ -1,6 +1,19 @@
module.exports = function (api) { module.exports = function (api) {
api.cache(true); api.cache(true);
const plugins = [];
// Remove console.log and console.warn in production builds
// Keep console.error for actual error reporting
if (process.env.NODE_ENV === 'production') {
plugins.push([
'transform-remove-console',
{exclude: ['error']},
]);
}
return { return {
presets: ['babel-preset-expo'], presets: ['babel-preset-expo'],
plugins,
}; };
}; };

8
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"babel-plugin-transform-remove-console": "^6.9.4",
"eslint": "^8.19.0", "eslint": "^8.19.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"prettier": "2.8.8", "prettier": "2.8.8",
@@ -5311,6 +5312,13 @@
"@babel/plugin-syntax-flow": "^7.12.1" "@babel/plugin-syntax-flow": "^7.12.1"
} }
}, },
"node_modules/babel-plugin-transform-remove-console": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz",
"integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==",
"dev": true,
"license": "MIT"
},
"node_modules/babel-preset-current-node-syntax": { "node_modules/babel-preset-current-node-syntax": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz",

View File

@@ -39,6 +39,7 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-native-vector-icons": "^6.4.18", "@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"babel-plugin-transform-remove-console": "^6.9.4",
"eslint": "^8.19.0", "eslint": "^8.19.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"prettier": "2.8.8", "prettier": "2.8.8",

View File

@@ -9,6 +9,16 @@ import {detectChip} from '../services/detection';
import type {ScanState, RawTagData, ScanError, NFCStatus} from '../types/nfc'; import type {ScanState, RawTagData, ScanError, NFCStatus} from '../types/nfc';
import type {Transponder} from '../types/detection'; import type {Transponder} from '../types/detection';
/** Progress update during scanning */
export interface ScanProgress {
/** Current step description */
step: string;
/** Step number (1-indexed) */
current: number;
/** Total steps (if known) */
total?: number;
}
export interface UseScanResult { export interface UseScanResult {
/** Current scan state */ /** Current scan state */
state: ScanState; state: ScanState;
@@ -20,6 +30,8 @@ export interface UseScanResult {
error: ScanError | null; error: ScanError | null;
/** NFC status (supported and enabled) */ /** NFC status (supported and enabled) */
nfcStatus: NFCStatus; nfcStatus: NFCStatus;
/** Current scan progress (during 'scanning' state) */
scanProgress: ScanProgress | null;
/** Start scanning for a tag */ /** Start scanning for a tag */
startScan: () => Promise<void>; startScan: () => Promise<void>;
/** Cancel ongoing scan */ /** Cancel ongoing scan */
@@ -42,6 +54,7 @@ export function useScan(): UseScanResult {
isSupported: false, isSupported: false,
isEnabled: false, isEnabled: false,
}); });
const [scanProgress, setScanProgress] = useState<ScanProgress | null>(null);
// Track if component is mounted to prevent state updates after unmount // Track if component is mounted to prevent state updates after unmount
const isMounted = useRef(true); const isMounted = useRef(true);
@@ -83,6 +96,7 @@ export function useScan(): UseScanResult {
setTag(null); setTag(null);
setTransponder(null); setTransponder(null);
setError(null); setError(null);
setScanProgress({step: 'Waiting for tag...', current: 1});
try { try {
// Check NFC status first // Check NFC status first
@@ -115,8 +129,23 @@ export function useScan(): UseScanResult {
// Perform scan with detection in one session // Perform scan with detection in one session
const result = await nfcManager.scanWithDetection(async tagData => { const result = await nfcManager.scanWithDetection(async tagData => {
// Update progress: tag detected
if (isMounted.current) {
setScanProgress({step: 'Tag detected, identifying chip...', current: 2});
}
// Run chip detection while NFC session is still active // Run chip detection while NFC session is still active
const detectionResult = await detectChip(tagData); const detectionResult = await detectChip(tagData, step => {
// Progress callback from detection
if (isMounted.current) {
setScanProgress({step, current: 3});
}
});
if (isMounted.current) {
setScanProgress({step: 'Detection complete', current: 4});
}
return detectionResult.success ? detectionResult.transponder : null; return detectionResult.success ? detectionResult.transponder : null;
}); });
@@ -175,6 +204,7 @@ export function useScan(): UseScanResult {
setTag(null); setTag(null);
setTransponder(null); setTransponder(null);
setError(null); setError(null);
setScanProgress(null);
}, []); }, []);
// Open device NFC settings // Open device NFC settings
@@ -193,6 +223,7 @@ export function useScan(): UseScanResult {
transponder, transponder,
error, error,
nfcStatus, nfcStatus,
scanProgress,
startScan, startScan,
cancelScan, cancelScan,
reset, reset,

View File

@@ -1,13 +1,16 @@
import React, {useMemo} from 'react'; import React, { useMemo, useState } from 'react';
import {StyleSheet, View, ScrollView, Linking} from 'react-native'; import { StyleSheet, View, ScrollView, Linking, TouchableOpacity } from 'react-native';
import {Button, Text, Surface, Divider, Chip} from 'react-native-paper'; import { Button, Text, Surface, Divider, Chip } from 'react-native-paper';
import type {ResultScreenProps} from '../types/navigation'; import type { ResultScreenProps } from '../types/navigation';
import {DTColors} from '../theme'; import { DTColors } from '../theme';
import {matchChipToProducts, getMatchSummary, getDesfireEvMismatchWarning} from '../services/matching'; import { matchChipToProducts, getMatchSummary, getDesfireEvMismatchWarning } from '../services/matching';
import {Product} from '../types/products'; import { Product } from '../types/products';
import { getChipInfo, getChipFamilyInfo, getSecurityLevelDescription } from '../data/chipInfo';
import { getChipFamily } from '../types/detection';
export function ResultScreen({route, navigation}: ResultScreenProps) { export function ResultScreen({ route, navigation }: ResultScreenProps) {
const {tagData, transponder} = route.params; const { tagData, transponder } = route.params;
const [showChipInfo, setShowChipInfo] = useState(false);
// Match chip to products // Match chip to products
const matchResult = useMemo(() => { const matchResult = useMemo(() => {
@@ -15,6 +18,17 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
return matchChipToProducts(transponder.type); return matchChipToProducts(transponder.type);
}, [transponder]); }, [transponder]);
// Get educational chip info
const chipInfo = useMemo(() => {
if (!transponder) return null;
return getChipInfo(transponder.type);
}, [transponder]);
const chipFamilyInfo = useMemo(() => {
if (!transponder) return null;
return getChipFamilyInfo(getChipFamily(transponder.type));
}, [transponder]);
const handleConversionLink = () => { const handleConversionLink = () => {
Linking.openURL(matchResult?.conversionUrl || 'https://dngr.us/conversion'); Linking.openURL(matchResult?.conversionUrl || 'https://dngr.us/conversion');
}; };
@@ -36,7 +50,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
medium: DTColors.modeEmphasis, medium: DTColors.modeEmphasis,
low: DTColors.modeWarning, low: DTColors.modeWarning,
}; };
return {color: colors[transponder.confidence], label: `${transponder.confidence.toUpperCase()} CONFIDENCE`}; return { color: colors[transponder.confidence], label: `${transponder.confidence.toUpperCase()} CONFIDENCE` };
}; };
return ( return (
@@ -54,16 +68,39 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
{transponder.chipName} {transponder.chipName}
</Text> </Text>
{/* Show implant name if detected from memory, or payment device */}
{transponder.implantName && (
transponder.implantName.includes('Payment Card') ? (
<View style={styles.paymentDeviceRow}>
<Text variant="labelMedium" style={styles.paymentDeviceLabel}>
PAYMENT DEVICE DETECTED
</Text>
<Text variant="titleLarge" style={styles.paymentDeviceValue}>
{transponder.implantName.replace(' Payment Card', '')}
</Text>
</View>
) : (
<View style={styles.implantNameRow}>
<Text variant="labelMedium" style={styles.implantNameLabel}>
IMPLANT DETECTED
</Text>
<Text variant="titleLarge" style={styles.implantNameValue}>
{transponder.implantName}
</Text>
</View>
)
)}
<View style={styles.chipMeta}> <View style={styles.chipMeta}>
<Chip <Chip
style={[styles.familyChip, {borderColor: DTColors.modeNormal}]} style={[styles.familyChip, { borderColor: DTColors.modeNormal }]}
textStyle={styles.familyChipText}> textStyle={styles.familyChipText}>
{transponder.family} {transponder.family}
</Chip> </Chip>
{getConfidenceLabel() && ( {getConfidenceLabel() && (
<Chip <Chip
style={[styles.confidenceChip, {borderColor: getConfidenceLabel()!.color}]} style={[styles.confidenceChip, { borderColor: getConfidenceLabel()!.color }]}
textStyle={[styles.confidenceChipText, {color: getConfidenceLabel()!.color}]}> textStyle={[styles.confidenceChipText, { color: getConfidenceLabel()!.color }]}>
{getConfidenceLabel()!.label} {getConfidenceLabel()!.label}
</Chip> </Chip>
)} )}
@@ -86,7 +123,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
</Text> </Text>
<Text <Text
variant="bodyLarge" variant="bodyLarge"
style={[styles.detailValue, {color: getCloneabilityColor()}]}> style={[styles.detailValue, { color: getCloneabilityColor() }]}>
{transponder.isCloneable ? 'YES' : 'NO'} {transponder.isCloneable ? 'YES' : 'NO'}
</Text> </Text>
</View> </View>
@@ -99,13 +136,114 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
</Surface> </Surface>
)} )}
{/* Product Matches Card */} {/* Educational Chip Info Card */}
{transponder && matchResult && (matchResult.exactMatches.length > 0 || matchResult.cloneTargets.length > 0) && ( {transponder && (chipInfo || chipFamilyInfo) && (
<Surface style={styles.chipInfoCard} elevation={1}>
<TouchableOpacity
onPress={() => setShowChipInfo(!showChipInfo)}
style={styles.chipInfoHeader}
activeOpacity={0.7}>
<Text variant="labelLarge" style={styles.chipInfoLabel}>
ABOUT THIS CHIP
</Text>
<Text style={styles.expandIndicator}>
{showChipInfo ? '▼' : '▶'}
</Text>
</TouchableOpacity>
{showChipInfo && (
<>
<Divider style={[styles.divider, { backgroundColor: DTColors.modeOther }]} />
{/* Family description */}
{chipFamilyInfo && (
<Text variant="bodyMedium" style={styles.chipFamilyDescription}>
{chipFamilyInfo}
</Text>
)}
{/* Detailed chip info */}
{chipInfo && (
<>
<Text variant="bodyMedium" style={styles.chipDescription}>
{chipInfo.description}
</Text>
{chipInfo.memoryNote && (
<View style={styles.chipInfoRow}>
<Text variant="bodySmall" style={styles.chipInfoRowLabel}>
Memory:
</Text>
<Text variant="bodySmall" style={styles.chipInfoRowValue}>
{chipInfo.memoryNote}
</Text>
</View>
)}
<View style={styles.chipInfoRow}>
<Text variant="bodySmall" style={styles.chipInfoRowLabel}>
Security:
</Text>
<Text
variant="bodySmall"
style={[
styles.chipInfoRowValue,
{
color:
chipInfo.securityLevel === 'high'
? DTColors.modeSuccess
: chipInfo.securityLevel === 'medium'
? DTColors.modeEmphasis
: DTColors.modeWarning,
},
]}>
{chipInfo.securityLevel.toUpperCase()}
</Text>
</View>
<Text variant="bodySmall" style={styles.securityNote}>
{getSecurityLevelDescription(chipInfo.securityLevel)}
</Text>
{chipInfo.commonUses.length > 0 && (
<>
<Text variant="labelSmall" style={styles.chipInfoSectionLabel}>
COMMON USES
</Text>
{chipInfo.commonUses.map((use, idx) => (
<Text key={idx} variant="bodySmall" style={styles.chipInfoListItem}>
{use}
</Text>
))}
</>
)}
{chipInfo.capabilities.length > 0 && (
<>
<Text variant="labelSmall" style={styles.chipInfoSectionLabel}>
CAPABILITIES
</Text>
{chipInfo.capabilities.map((cap, idx) => (
<Text key={idx} variant="bodySmall" style={styles.chipInfoListItem}>
{cap}
</Text>
))}
</>
)}
</>
)}
</>
)}
</Surface>
)}
{/* Product Matches Card - hide for payment devices */}
{transponder && matchResult && (matchResult.exactMatches.length > 0 || matchResult.cloneTargets.length > 0) && !transponder.implantName?.includes('Payment Card') && (
<Surface style={styles.productsCard} elevation={1}> <Surface style={styles.productsCard} elevation={1}>
<Text variant="labelLarge" style={styles.productsLabel}> <Text variant="labelLarge" style={styles.productsLabel}>
COMPATIBLE IMPLANTS COMPATIBLE IMPLANTS
</Text> </Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeEmphasis}]} /> <Divider style={[styles.divider, { backgroundColor: DTColors.modeEmphasis }]} />
<Text variant="bodyMedium" style={styles.matchSummary}> <Text variant="bodyMedium" style={styles.matchSummary}>
{getMatchSummary(matchResult, transponder.chipName)} {getMatchSummary(matchResult, transponder.chipName)}
@@ -171,7 +309,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
<Text variant="labelLarge" style={styles.noMatchLabel}> <Text variant="labelLarge" style={styles.noMatchLabel}>
NO DIRECT MATCH NO DIRECT MATCH
</Text> </Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeOther}]} /> <Divider style={[styles.divider, { backgroundColor: DTColors.modeOther }]} />
<Text variant="bodyMedium" style={styles.noMatchText}> <Text variant="bodyMedium" style={styles.noMatchText}>
{getMatchSummary(matchResult, transponder.chipName)} {getMatchSummary(matchResult, transponder.chipName)}
@@ -198,7 +336,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
<Text variant="labelLarge" style={styles.sakSwapLabel}> <Text variant="labelLarge" style={styles.sakSwapLabel}>
SAK SWAP DETECTED SAK SWAP DETECTED
</Text> </Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeEmphasis}]} /> <Divider style={[styles.divider, { backgroundColor: DTColors.modeEmphasis }]} />
<Text variant="bodyLarge" style={styles.sakSwapType}> <Text variant="bodyLarge" style={styles.sakSwapType}>
{transponder.sakSwapInfo.swapType?.replace(/_/g, ' ').toUpperCase()} {transponder.sakSwapInfo.swapType?.replace(/_/g, ' ').toUpperCase()}
@@ -293,18 +431,21 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
<Text variant="labelLarge" style={styles.noDetectionLabel}> <Text variant="labelLarge" style={styles.noDetectionLabel}>
CHIP NOT IDENTIFIED CHIP NOT IDENTIFIED
</Text> </Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeWarning}]} /> <Divider style={[styles.divider, { backgroundColor: DTColors.modeWarning }]} />
<Text variant="bodyMedium" style={styles.noDetectionText}> <Text variant="bodyMedium" style={styles.noDetectionText}>
Unable to identify this chip type. It may be unsupported or require advanced detection. Unable to identify this chip type. It may be unsupported or require advanced detection.
</Text> </Text>
</Surface> </Surface>
)} )}
{/* Conversion Service Card - show when recommended */} {/* Conversion Service Card - show for payment devices, or when recommended and not a real implant */}
{(matchResult?.conversionRecommended || !transponder) && ( {(transponder?.implantName?.includes('Payment Card') ||
((matchResult?.conversionRecommended || !transponder) && !transponder?.implantName)) && (
<Surface style={styles.conversionCard} elevation={1}> <Surface style={styles.conversionCard} elevation={1}>
<Text variant="bodyMedium" style={styles.conversionText}> <Text variant="bodyMedium" style={styles.conversionText}>
{!transponder {transponder?.implantName?.includes('Payment Card')
? "Payment cards can't be converted to implants due to security restrictions. Our conversion service can help you find an implant that works with your use case."
: !transponder
? 'Unknown chip? Our conversion service can help.' ? 'Unknown chip? Our conversion service can help.'
: matchResult?.isCloneable === false : matchResult?.isCloneable === false
? "This chip uses cryptographic protection and can't be cloned. Our conversion service can help find alternatives." ? "This chip uses cryptographic protection and can't be cloned. Our conversion service can help find alternatives."
@@ -536,7 +677,7 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent', backgroundColor: 'transparent',
borderWidth: 1, borderWidth: 1,
borderColor: DTColors.modeNormal, borderColor: DTColors.modeNormal,
height: 24, height: 35,
}, },
formFactorChipText: { formFactorChipText: {
color: DTColors.modeNormal, color: DTColors.modeNormal,
@@ -645,4 +786,109 @@ const styles = StyleSheet.create({
color: DTColors.light, color: DTColors.light,
opacity: 0.6, opacity: 0.6,
}, },
// Chip Info Card
chipInfoCard: {
backgroundColor: '#0a0a0a',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeOther,
padding: 20,
marginBottom: 20,
},
chipInfoHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
chipInfoLabel: {
color: DTColors.modeOther,
letterSpacing: 2,
},
expandIndicator: {
color: DTColors.modeOther,
fontSize: 12,
},
chipFamilyDescription: {
color: DTColors.light,
opacity: 0.8,
marginBottom: 12,
lineHeight: 22,
},
chipDescription: {
color: DTColors.light,
marginBottom: 16,
lineHeight: 22,
},
chipInfoRow: {
flexDirection: 'row',
marginBottom: 4,
},
chipInfoRowLabel: {
color: DTColors.light,
opacity: 0.6,
marginRight: 8,
},
chipInfoRowValue: {
color: DTColors.light,
},
securityNote: {
color: DTColors.light,
opacity: 0.5,
fontStyle: 'italic',
marginTop: 4,
marginBottom: 16,
},
chipInfoSectionLabel: {
color: DTColors.modeOther,
letterSpacing: 1,
marginTop: 8,
marginBottom: 8,
},
chipInfoListItem: {
color: DTColors.light,
opacity: 0.8,
marginBottom: 4,
},
// Implant name styles
implantNameRow: {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeSuccess,
padding: 12,
marginTop: 12,
marginBottom: 8,
alignItems: 'center',
},
implantNameLabel: {
color: DTColors.modeSuccess,
letterSpacing: 2,
marginBottom: 4,
},
implantNameValue: {
color: DTColors.modeSuccess,
fontWeight: 'bold',
letterSpacing: 1,
},
// Payment device styles
paymentDeviceRow: {
backgroundColor: 'rgba(255, 255, 0, 0.1)',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeEmphasis,
padding: 12,
marginTop: 12,
marginBottom: 8,
alignItems: 'center',
},
paymentDeviceLabel: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
marginBottom: 4,
},
paymentDeviceValue: {
color: DTColors.modeEmphasis,
fontWeight: 'bold',
letterSpacing: 1,
},
}); });

View File

@@ -1,10 +1,11 @@
import React, {useCallback, useEffect} from 'react'; import React, {useCallback, useEffect} from 'react';
import {StyleSheet, View, Platform} from 'react-native'; import {StyleSheet, View, Platform} from 'react-native';
import {Button, Text, ActivityIndicator} from 'react-native-paper'; import {Button, Text} from 'react-native-paper';
import type {ScanScreenProps} from '../types/navigation'; import type {ScanScreenProps} from '../types/navigation';
import {DTColors} from '../theme'; import {DTColors} from '../theme';
import {useScan} from '../hooks'; import {useScan} from '../hooks';
import {getScanInstructions} from '../services/nfc'; import {getScanInstructions} from '../services/nfc';
import {ScanAnimation} from '../components';
export function ScanScreen({navigation}: ScanScreenProps) { export function ScanScreen({navigation}: ScanScreenProps) {
const { const {
@@ -13,6 +14,7 @@ export function ScanScreen({navigation}: ScanScreenProps) {
transponder, transponder,
error, error,
nfcStatus, nfcStatus,
scanProgress,
startScan, startScan,
cancelScan, cancelScan,
openSettings, openSettings,
@@ -56,16 +58,46 @@ export function ScanScreen({navigation}: ScanScreenProps) {
startScan(); startScan();
}, [startScan]); }, [startScan]);
// Get contextual error hints based on error type
const getErrorHint = () => {
if (!error?.type) return null;
switch (error.type) {
case 'TAG_LOST':
return 'Hold the tag steady against the back of your phone until scanning completes.';
case 'TIMEOUT':
return 'The tag took too long to respond. Try repositioning it closer to the NFC reader.';
case 'SCAN_CANCELLED':
return null; // User cancelled, no hint needed
case 'NFC_NOT_ENABLED':
return Platform.OS === 'ios'
? 'Go to Settings > NFC to enable NFC scanning.'
: 'Enable NFC in your device settings.';
case 'PERMISSION_DENIED':
return 'NFC permission was denied. Please grant NFC access in your device settings.';
case 'UNKNOWN':
return 'An unexpected error occurred. Make sure the tag is positioned correctly and try again.';
default:
return 'Make sure the tag is positioned correctly and try again.';
}
};
// Render NFC not supported state // Render NFC not supported state
if (!nfcStatus.isSupported && state !== 'scanning') { if (!nfcStatus.isSupported && state !== 'scanning') {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.errorIcon}>
<Text style={styles.errorIconText}></Text>
</View>
<Text variant="headlineMedium" style={styles.errorText}> <Text variant="headlineMedium" style={styles.errorText}>
NFC NOT SUPPORTED NFC NOT SUPPORTED
</Text> </Text>
<Text variant="bodyLarge" style={styles.errorMessage}> <Text variant="bodyLarge" style={styles.errorMessage}>
This device does not support NFC scanning. This device does not have NFC hardware.
</Text>
<Text variant="bodyMedium" style={styles.hintText}>
NFC scanning requires a device with NFC capability. Most modern smartphones have NFC, but it may need to be enabled in settings.
</Text> </Text>
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
@@ -85,18 +117,26 @@ export function ScanScreen({navigation}: ScanScreenProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.warningIcon}>
<Text style={styles.warningIconText}></Text>
</View>
<Text variant="headlineMedium" style={styles.warningText}> <Text variant="headlineMedium" style={styles.warningText}>
NFC DISABLED NFC DISABLED
</Text> </Text>
<Text variant="bodyLarge" style={styles.errorMessage}> <Text variant="bodyLarge" style={styles.errorMessage}>
Please enable NFC in your device settings to scan tags. NFC is turned off on this device.
</Text>
<Text variant="bodyMedium" style={styles.hintText}>
{Platform.OS === 'ios'
? 'Go to Settings and enable NFC scanning to continue.'
: 'Enable NFC in your device settings to scan tags.'}
</Text> </Text>
{Platform.OS === 'android' && ( {Platform.OS === 'android' && (
<Button <Button
mode="outlined" mode="outlined"
onPress={openSettings} onPress={openSettings}
style={styles.settingsButton} style={styles.settingsButton}
labelStyle={styles.buttonLabel}> labelStyle={styles.settingsButtonLabel}>
OPEN SETTINGS OPEN SETTINGS
</Button> </Button>
)} )}
@@ -124,34 +164,41 @@ export function ScanScreen({navigation}: ScanScreenProps) {
<View style={styles.content}> <View style={styles.content}>
{state === 'scanning' && ( {state === 'scanning' && (
<> <>
<View style={styles.scanIndicator}> <ScanAnimation isActive={true} size={180} />
<ActivityIndicator
size="large"
color={DTColors.modeNormal}
style={styles.spinner}
/>
<View style={styles.pulseRing} />
</View>
<Text variant="headlineMedium" style={styles.scanningText}> <Text variant="headlineMedium" style={styles.scanningText}>
SCANNING SCANNING
</Text> </Text>
<Text variant="bodyLarge" style={styles.instructionText}> <Text variant="bodyLarge" style={styles.instructionText}>
{getScanInstructions()} {getScanInstructions()}
</Text> </Text>
{scanProgress && scanProgress.current > 1 && (
<Text variant="bodySmall" style={styles.progressText}>
{scanProgress.step}
</Text>
)}
</> </>
)} )}
{state === 'error' && ( {state === 'error' && (
<> <>
<View style={styles.errorIcon}>
<Text style={styles.errorIconText}>!</Text>
</View>
<Text variant="headlineMedium" style={styles.errorText}> <Text variant="headlineMedium" style={styles.errorText}>
SCAN FAILED {error?.type === 'TAG_LOST'
? 'TAG LOST'
: error?.type === 'TIMEOUT'
? 'TIMEOUT'
: error?.type === 'PERMISSION_DENIED'
? 'PERMISSION DENIED'
: 'SCAN FAILED'}
</Text> </Text>
<Text variant="bodyLarge" style={styles.errorMessage}> <Text variant="bodyLarge" style={styles.errorMessage}>
{error?.message || 'Unable to read NFC tag'} {error?.message || 'Unable to read NFC tag'}
</Text> </Text>
{error?.type === 'TAG_LOST' && ( {getErrorHint() && (
<Text variant="bodyMedium" style={styles.hintText}> <Text variant="bodyMedium" style={styles.hintText}>
Hold the tag steady until the scan completes {getErrorHint()}
</Text> </Text>
)} )}
<Button <Button
@@ -181,10 +228,10 @@ export function ScanScreen({navigation}: ScanScreenProps) {
{state === 'processing' && ( {state === 'processing' && (
<> <>
<ActivityIndicator <ScanAnimation
size="large" isActive={true}
size={180}
color={DTColors.modeEmphasis} color={DTColors.modeEmphasis}
style={styles.processingSpinner}
/> />
<Text variant="headlineMedium" style={styles.processingText}> <Text variant="headlineMedium" style={styles.processingText}>
IDENTIFYING IDENTIFYING
@@ -219,27 +266,10 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
scanIndicator: {
width: 150,
height: 150,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 40,
},
spinner: {
position: 'absolute',
},
pulseRing: {
width: 120,
height: 120,
borderRadius: 60,
borderWidth: 2,
borderColor: DTColors.modeNormal,
opacity: 0.3,
},
scanningText: { scanningText: {
color: DTColors.modeNormal, color: DTColors.modeNormal,
letterSpacing: 4, letterSpacing: 4,
marginTop: 32,
marginBottom: 16, marginBottom: 16,
}, },
instructionText: { instructionText: {
@@ -248,11 +278,47 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
progressText: {
color: DTColors.modeNormal,
opacity: 0.7,
marginTop: 16,
textAlign: 'center',
fontStyle: 'italic',
},
errorIcon: {
width: 80,
height: 80,
borderRadius: 40,
borderWidth: 3,
borderColor: DTColors.modeWarning,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
errorIconText: {
color: DTColors.modeWarning,
fontSize: 48,
fontWeight: 'bold',
},
errorText: { errorText: {
color: DTColors.modeWarning, color: DTColors.modeWarning,
letterSpacing: 2, letterSpacing: 2,
marginBottom: 16, marginBottom: 16,
}, },
warningIcon: {
width: 80,
height: 80,
borderRadius: 40,
borderWidth: 3,
borderColor: DTColors.modeEmphasis,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
warningIconText: {
color: DTColors.modeEmphasis,
fontSize: 36,
},
warningText: { warningText: {
color: DTColors.modeEmphasis, color: DTColors.modeEmphasis,
letterSpacing: 2, letterSpacing: 2,
@@ -282,6 +348,10 @@ const styles = StyleSheet.create({
borderWidth: 2, borderWidth: 2,
marginTop: 16, marginTop: 16,
}, },
settingsButtonLabel: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
},
readyText: { readyText: {
color: DTColors.modeEmphasis, color: DTColors.modeEmphasis,
letterSpacing: 2, letterSpacing: 2,
@@ -295,12 +365,10 @@ const styles = StyleSheet.create({
color: DTColors.modeNormal, color: DTColors.modeNormal,
letterSpacing: 2, letterSpacing: 2,
}, },
processingSpinner: {
marginBottom: 24,
},
processingText: { processingText: {
color: DTColors.modeEmphasis, color: DTColors.modeEmphasis,
letterSpacing: 4, letterSpacing: 4,
marginTop: 32,
}, },
detectingHint: { detectingHint: {
color: DTColors.light, color: DTColors.light,

View File

@@ -10,6 +10,8 @@ import {
DESFIRE_GET_VERSION_CONTINUE, DESFIRE_GET_VERSION_CONTINUE,
sendIsoDepCommand, sendIsoDepCommand,
parseApduResponse, parseApduResponse,
selectAid,
KNOWN_AIDS,
} from '../nfc/commands'; } from '../nfc/commands';
/** /**
@@ -405,3 +407,110 @@ export function formatDesfireVersionInfo(info: DesfireVersionInfo): string {
: ''; : '';
return version + software; return version + software;
} }
/**
* Result of Spark 2 detection
*/
export interface Spark2DetectionResult {
found: boolean;
name?: string;
url?: string;
}
/**
* Detect Spark 2 implant by reading NDEF from NTAG 424 DNA
* Spark 2 implants have a URI record pointing to vivokey.co/$code
*
* Uses Type 4 Tag NDEF reading:
* 1. SELECT NDEF application
* 2. SELECT NDEF file (E104)
* 3. READ BINARY to get NDEF message
*/
export async function detectSpark2Implant(): Promise<Spark2DetectionResult> {
try {
console.log('[DESFire] Checking for Spark 2 implant NDEF...');
// Step 1: Select NDEF application
const selectNdefApp = await sendIsoDepCommand(selectAid(KNOWN_AIDS.ndefTag));
const selectAppResponse = parseApduResponse(selectNdefApp);
if (!selectAppResponse.isSuccess) {
console.log('[DESFire] NDEF app selection failed');
return {found: false};
}
// Step 2: Select NDEF file (file ID E104 for standard NDEF)
// SELECT command: 00 A4 00 0C 02 E104
const selectFileCmd = [0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x04];
const selectFileResponse = await sendIsoDepCommand(selectFileCmd);
const selectFileParsed = parseApduResponse(selectFileResponse);
if (!selectFileParsed.isSuccess) {
console.log('[DESFire] NDEF file selection failed');
return {found: false};
}
// Step 3: Read NDEF length (first 2 bytes)
// READ BINARY: 00 B0 00 00 02
const readLengthCmd = [0x00, 0xb0, 0x00, 0x00, 0x02];
const readLengthResponse = await sendIsoDepCommand(readLengthCmd);
const lengthParsed = parseApduResponse(readLengthResponse);
if (!lengthParsed.isSuccess || lengthParsed.data.length < 2) {
console.log('[DESFire] NDEF length read failed');
return {found: false};
}
const ndefLength = (lengthParsed.data[0] << 8) | lengthParsed.data[1];
console.log('[DESFire] NDEF length:', ndefLength);
if (ndefLength === 0 || ndefLength > 500) {
console.log('[DESFire] Invalid NDEF length');
return {found: false};
}
// Step 4: Read NDEF message (starting after length bytes)
// READ BINARY: 00 B0 00 02 <length>
const readDataCmd = [0x00, 0xb0, 0x00, 0x02, Math.min(ndefLength, 128)];
const readDataResponse = await sendIsoDepCommand(readDataCmd);
const dataParsed = parseApduResponse(readDataResponse);
if (!dataParsed.isSuccess || dataParsed.data.length === 0) {
console.log('[DESFire] NDEF data read failed');
return {found: false};
}
console.log(
'[DESFire] NDEF data:',
dataParsed.data.map(b => b.toString(16).padStart(2, '0')).join(' '),
);
// Convert to ASCII and look for vivokey.co pattern
const asciiStr = dataParsed.data
.filter(b => b >= 0x20 && b <= 0x7e)
.map(b => String.fromCharCode(b))
.join('');
console.log('[DESFire] NDEF ASCII:', asciiStr);
// Check for vivokey.co URL pattern
const vivokeyMatch = asciiStr.match(/vivokey\.co\/([A-Za-z0-9]+)/i);
if (vivokeyMatch) {
const code = vivokeyMatch[1];
const url = `https://vivokey.co/${code}`;
console.log('[DESFire] Found Spark 2 URL:', url);
return {
found: true,
name: 'Spark 2',
url,
};
}
console.log('[DESFire] No vivokey.co URL found');
return {found: false};
} catch (error) {
console.warn('[DESFire] Spark 2 detection failed:', error);
return {found: false};
}
}

View File

@@ -14,15 +14,15 @@ import {
CHIP_MEMORY_SIZES, CHIP_MEMORY_SIZES,
CHIP_CLONEABILITY, CHIP_CLONEABILITY,
} from '../../types/detection'; } from '../../types/detection';
import {detectNtag, mightBeNtag} from './ntag'; import {detectNtag, mightBeNtag, detectImplantNameInMemory} from './ntag';
import { import {
detectMifareClassic, detectMifareClassic,
isMifareClassicSak, isMifareClassicSak,
hasIsoDepCapability, hasIsoDepCapability,
detectSakSwap, detectSakSwap,
} from './mifare'; } from './mifare';
import {detectDesfire, detectDesfireFromAts} from './desfire'; import {detectDesfire, detectDesfireFromAts, detectSpark2Implant} from './desfire';
import {detectIso15693, isIso15693} from './iso15693'; import {detectIso15693, isIso15693, detectSparkImplant} from './iso15693';
import {detectJavaCard, mightBeJavaCard, detectJavaCardFromAts} from './javacard'; import {detectJavaCard, mightBeJavaCard, detectJavaCardFromAts} from './javacard';
/** /**
@@ -36,6 +36,7 @@ function createTransponder(
versionInfo?: Transponder['versionInfo']; versionInfo?: Transponder['versionInfo'];
confidence?: Transponder['confidence']; confidence?: Transponder['confidence'];
sakSwapInfo?: Transponder['sakSwapInfo']; sakSwapInfo?: Transponder['sakSwapInfo'];
implantName?: string;
} = {}, } = {},
): Transponder { ): Transponder {
const cloneInfo = CHIP_CLONEABILITY[type]; const cloneInfo = CHIP_CLONEABILITY[type];
@@ -67,11 +68,47 @@ function createTransponder(
}, },
versionInfo: options.versionInfo, versionInfo: options.versionInfo,
sakSwapInfo, sakSwapInfo,
implantName: options.implantName,
confidence: options.confidence ?? 'medium', confidence: options.confidence ?? 'medium',
detectedOn: Platform.OS as 'ios' | 'android', detectedOn: Platform.OS as 'ios' | 'android',
}; };
} }
/**
* Determine implant name based on detected JavaCard applets
* - Fidesmo indicates Apex (only Apex has Fidesmo platform)
* - JavaCard Memory indicates Apex or flexSecure (both have it)
* - Payment applets indicate this is a payment card, not an implant
*/
function getJavacardImplantName(installedApplets?: string[]): string | undefined {
if (!installedApplets || installedApplets.length === 0) {
return undefined;
}
// Payment card detection - not an implant
if (installedApplets.includes('Payment (PPSE)')) {
const network = installedApplets.find(a =>
['Visa', 'Mastercard', 'American Express', 'Discover', 'Maestro'].includes(a),
);
return network ? `${network} Payment Card` : 'Payment Card';
}
// Fidesmo is only on Apex
if (installedApplets.includes('Fidesmo')) {
return 'Apex';
}
// JavaCard Memory is on both Apex and flexSecure
if (installedApplets.includes('JavaCard Memory')) {
return 'Apex/flexSecure';
}
return undefined;
}
/** Progress callback type for detection updates */
export type DetectionProgressCallback = (step: string) => void;
/** /**
* Detect chip type using waterfall approach * Detect chip type using waterfall approach
* *
@@ -81,7 +118,10 @@ function createTransponder(
* 3. Check for ISO-DEP capable chips (Phase 4: DESFire, Plus, JavaCard) * 3. Check for ISO-DEP capable chips (Phase 4: DESFire, Plus, JavaCard)
* 4. Fall back to generic type based on technology * 4. Fall back to generic type based on technology
*/ */
export async function detectChip(rawData: RawTagData): Promise<DetectionResult> { export async function detectChip(
rawData: RawTagData,
onProgress?: DetectionProgressCallback,
): Promise<DetectionResult> {
try { try {
const {sak, techTypes} = rawData; const {sak, techTypes} = rawData;
@@ -97,6 +137,8 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// Step 1: Check for MIFARE Classic // Step 1: Check for MIFARE Classic
// Use tech type detection first (most reliable on Android), then SAK // Use tech type detection first (most reliable on Android), then SAK
// ======================================================================== // ========================================================================
onProgress?.('Checking MIFARE Classic...');
const hasMifareClassicTech = techTypes.some(t => const hasMifareClassicTech = techTypes.some(t =>
t.includes('MifareClassic'), t.includes('MifareClassic'),
); );
@@ -157,6 +199,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
console.log('[Detector] mightBeNtag result:', couldBeNtag); console.log('[Detector] mightBeNtag result:', couldBeNtag);
if (couldBeNtag) { if (couldBeNtag) {
onProgress?.('Reading NTAG version...');
console.log('[Detector] Attempting NTAG detection...'); console.log('[Detector] Attempting NTAG detection...');
const ntagResult = await detectNtag(); const ntagResult = await detectNtag();
console.log('[Detector] NTAG detection result:', { console.log('[Detector] NTAG detection result:', {
@@ -166,6 +209,19 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
}); });
if (ntagResult.success && ntagResult.chipType) { if (ntagResult.success && ntagResult.chipType) {
// Try to detect implant name in last memory pages
let implantName: string | undefined;
try {
onProgress?.('Checking for implant signature...');
const implantResult = await detectImplantNameInMemory(ntagResult.chipType);
if (implantResult.found && implantResult.name) {
implantName = implantResult.name;
console.log('[Detector] Found implant name in memory:', implantName);
}
} catch (e) {
console.warn('[Detector] Implant name detection failed:', e);
}
return { return {
success: true, success: true,
transponder: createTransponder(ntagResult.chipType, rawData, { transponder: createTransponder(ntagResult.chipType, rawData, {
@@ -173,6 +229,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
versionInfo: ntagResult.versionInfo, versionInfo: ntagResult.versionInfo,
confidence: confidence:
ntagResult.chipType === ChipType.NTAG_UNKNOWN ? 'medium' : 'high', ntagResult.chipType === ChipType.NTAG_UNKNOWN ? 'medium' : 'high',
implantName,
}), }),
}; };
} }
@@ -197,8 +254,29 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
) { ) {
// 3a: Always try DESFire/NTAG 424 DNA detection first via GET_VERSION // 3a: Always try DESFire/NTAG 424 DNA detection first via GET_VERSION
// This command works on DESFire EV1/2/3, DESFire Light, NTAG 424 DNA // This command works on DESFire EV1/2/3, DESFire Light, NTAG 424 DNA
onProgress?.('Reading DESFire version...');
const desfireResult = await detectDesfire(); const desfireResult = await detectDesfire();
if (desfireResult.success && desfireResult.chipType) { if (desfireResult.success && desfireResult.chipType) {
// Check for Spark 2 implant on NTAG 424 DNA / NTAG 413 DNA
let implantName: string | undefined;
const isNtagDna =
desfireResult.chipType === ChipType.NTAG424_DNA ||
desfireResult.chipType === ChipType.NTAG424_DNA_TT ||
desfireResult.chipType === ChipType.NTAG413_DNA;
if (isNtagDna) {
try {
onProgress?.('Reading NDEF for Spark 2...');
const spark2Result = await detectSpark2Implant();
if (spark2Result.found && spark2Result.name) {
implantName = spark2Result.name;
console.log('[Detector] Found Spark 2 implant:', implantName);
}
} catch (e) {
console.warn('[Detector] Spark 2 detection failed:', e);
}
}
return { return {
success: true, success: true,
transponder: createTransponder(desfireResult.chipType, rawData, { transponder: createTransponder(desfireResult.chipType, rawData, {
@@ -208,6 +286,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
desfireResult.chipType === ChipType.DESFIRE_UNKNOWN desfireResult.chipType === ChipType.DESFIRE_UNKNOWN
? 'medium' ? 'medium'
: 'high', : 'high',
implantName,
}), }),
}; };
} }
@@ -231,13 +310,17 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// 3c: Try JavaCard detection (check historical bytes and CPLC) // 3c: Try JavaCard detection (check historical bytes and CPLC)
if (mightBeJavaCard(rawData.historicalBytes, rawData.ats)) { if (mightBeJavaCard(rawData.historicalBytes, rawData.ats)) {
onProgress?.('Probing JavaCard applets...');
const jcResult = await detectJavaCard(); const jcResult = await detectJavaCard();
if (jcResult.success && jcResult.chipType) { if (jcResult.success && jcResult.chipType) {
// Determine implant name based on detected applets
const implantName = getJavacardImplantName(jcResult.installedApplets);
return { return {
success: true, success: true,
transponder: createTransponder(jcResult.chipType, rawData, { transponder: createTransponder(jcResult.chipType, rawData, {
confidence: confidence:
jcResult.chipType === ChipType.JCOP4 ? 'high' : 'medium', jcResult.chipType === ChipType.JCOP4 ? 'high' : 'medium',
implantName,
}), }),
}; };
} }
@@ -259,12 +342,16 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// 3d: If DESFire and likely JavaCard checks failed, try JavaCard as general fallback // 3d: If DESFire and likely JavaCard checks failed, try JavaCard as general fallback
// (some JavaCards don't have obvious historical bytes) // (some JavaCards don't have obvious historical bytes)
onProgress?.('Probing for smartcard applets...');
const jcFallback = await detectJavaCard(); const jcFallback = await detectJavaCard();
if (jcFallback.success && jcFallback.chipType) { if (jcFallback.success && jcFallback.chipType) {
// Determine implant name based on detected applets
const implantName = getJavacardImplantName(jcFallback.installedApplets);
return { return {
success: true, success: true,
transponder: createTransponder(jcFallback.chipType, rawData, { transponder: createTransponder(jcFallback.chipType, rawData, {
confidence: 'medium', confidence: 'medium',
implantName,
}), }),
}; };
} }
@@ -296,6 +383,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// Step 4: Check for ISO 15693 (NFC-V) - SLIX and NTAG 5 detection // Step 4: Check for ISO 15693 (NFC-V) - SLIX and NTAG 5 detection
// ======================================================================== // ========================================================================
if (isIso15693(techTypes)) { if (isIso15693(techTypes)) {
onProgress?.('Reading ISO 15693 system info...');
const iso15693Result = await detectIso15693(); const iso15693Result = await detectIso15693();
if (iso15693Result.success && iso15693Result.chipType) { if (iso15693Result.success && iso15693Result.chipType) {
// Determine confidence based on whether we got a specific chip type // Determine confidence based on whether we got a specific chip type
@@ -315,10 +403,24 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
? 'high' ? 'high'
: 'medium'; : 'medium';
// Check for Spark implant by reading NDEF for vivokey.co URL
let implantName: string | undefined;
try {
onProgress?.('Reading NDEF for Spark 1...');
const sparkResult = await detectSparkImplant(iso15693Result.chipType);
if (sparkResult.found && sparkResult.name) {
implantName = sparkResult.name;
console.log('[Detector] Found Spark implant:', implantName);
}
} catch (e) {
console.warn('[Detector] Spark detection failed:', e);
}
return { return {
success: true, success: true,
transponder: createTransponder(iso15693Result.chipType, rawData, { transponder: createTransponder(iso15693Result.chipType, rawData, {
confidence, confidence,
implantName,
}), }),
}; };
} }

View File

@@ -4,7 +4,9 @@
*/ */
export {detectChip, getDetectionSummary, canDoAdvancedDetection} from './detector'; export {detectChip, getDetectionSummary, canDoAdvancedDetection} from './detector';
export {detectNtag, mightBeNtag, formatNtagVersionInfo} from './ntag'; export type {DetectionProgressCallback} from './detector';
export {detectNtag, mightBeNtag, formatNtagVersionInfo, detectImplantNameInMemory} from './ntag';
export type {ImplantNameResult} from './ntag';
export { export {
detectMifareClassic, detectMifareClassic,
isMifareClassicSak, isMifareClassicSak,
@@ -15,9 +17,9 @@ export {
IOS_MIFARE_CLASSIC_NOTE, IOS_MIFARE_CLASSIC_NOTE,
} from './mifare'; } from './mifare';
export type {SakSwapDetection} from './mifare'; export type {SakSwapDetection} from './mifare';
export {detectDesfire, mightBeDesfire, formatDesfireVersionInfo} from './desfire'; export {detectDesfire, mightBeDesfire, formatDesfireVersionInfo, detectSpark2Implant} from './desfire';
export type {DesfireDetectionResult} from './desfire'; export type {DesfireDetectionResult, Spark2DetectionResult} from './desfire';
export {detectIso15693, isIso15693, getIcManufacturerName} from './iso15693'; export {detectIso15693, isIso15693, getIcManufacturerName, detectSparkImplant} from './iso15693';
export type {Iso15693DetectionResult} from './iso15693'; export type {Iso15693DetectionResult, SparkDetectionResult} from './iso15693';
export {detectJavaCard, mightBeJavaCard, formatCPLC} from './javacard'; export {detectJavaCard, mightBeJavaCard, formatCPLC, hasJavacardMemory} from './javacard';
export type {JavaCardDetectionResult, CPLCData} from './javacard'; export type {JavaCardDetectionResult, CPLCData} from './javacard';

View File

@@ -10,7 +10,7 @@
import {Platform} from 'react-native'; import {Platform} from 'react-native';
import NfcManager from 'react-native-nfc-manager'; import NfcManager from 'react-native-nfc-manager';
import {ChipType} from '../../types/detection'; import {ChipType} from '../../types/detection';
import {getIso15693SystemInfo} from '../nfc/commands'; import {getIso15693SystemInfo, transceiveNfcV, iso15693ReadSingleBlock} from '../nfc/commands';
/** /**
* NXP ICODE IC manufacturer code * NXP ICODE IC manufacturer code
@@ -191,12 +191,12 @@ function getIcodeTypeFromIcReference(icRef: number): ChipType | null {
return ChipType.ICODE_DNA; return ChipType.ICODE_DNA;
} }
// High IC references (0x80-0x8F) might be older SLIX or special variants // High IC references (0x80-0x8F) - unknown, don't guess
if (icRef >= 0x80 && icRef < 0x90) { if (icRef >= 0x80 && icRef < 0x90) {
console.log( console.log(
`[ISO15693] High IC ref 0x${icRef.toString(16)}, treating as SLIX (legacy/special)`, `[ISO15693] High IC ref 0x${icRef.toString(16)} - unknown variant`,
); );
return ChipType.SLIX; return null;
} }
console.log( console.log(
@@ -431,10 +431,10 @@ export async function detectIso15693(): Promise<Iso15693DetectionResult> {
} }
// Got manufacturer NXP but couldn't determine exact variant // Got manufacturer NXP but couldn't determine exact variant
// Default to SLIX as it's the most common NXP ISO 15693 chip // Don't assume SLIX - return unknown with NXP manufacturer info
return { return {
success: true, success: true,
chipType: ChipType.SLIX, chipType: ChipType.ISO15693_UNKNOWN,
uid, uid,
icManufacturer: icManufacturer ?? NXP_IC_MFG_CODE, icManufacturer: icManufacturer ?? NXP_IC_MFG_CODE,
blockSize: sysInfo.blockSize, blockSize: sysInfo.blockSize,
@@ -463,9 +463,9 @@ export async function detectIso15693(): Promise<Iso15693DetectionResult> {
console.log('[ISO15693] NXP manufacturer detected'); console.log('[ISO15693] NXP manufacturer detected');
// Check if we got IC reference from UID parsing // Check if we got IC reference from UID parsing
if (uidIcReference !== undefined) { if (uidIcReference !== undefined) {
const chipType = getIcodeTypeFromIcReference(uidIcReference) || ChipType.SLIX; const chipType = getIcodeTypeFromIcReference(uidIcReference);
if (chipType) {
console.log('[ISO15693] UID-based detection result:', chipType); console.log('[ISO15693] UID-based detection result:', chipType);
return { return {
success: true, success: true,
chipType, chipType,
@@ -474,14 +474,16 @@ export async function detectIso15693(): Promise<Iso15693DetectionResult> {
icReference: uidIcReference, icReference: uidIcReference,
}; };
} }
}
// NXP but no IC reference - assume SLIX (most common) // NXP but couldn't determine exact variant - return unknown with NXP info
console.log('[ISO15693] NXP but no IC reference, defaulting to SLIX'); console.log('[ISO15693] NXP but could not determine specific chip type');
return { return {
success: true, success: true,
chipType: ChipType.SLIX, chipType: ChipType.ISO15693_UNKNOWN,
uid, uid,
icManufacturer, icManufacturer,
icReference: uidIcReference,
}; };
} }
@@ -746,3 +748,103 @@ export function getIcManufacturerName(code: number): string {
return manufacturers[code] || `Unknown (0x${code.toString(16)})`; return manufacturers[code] || `Unknown (0x${code.toString(16)})`;
} }
/**
* Result of Spark implant detection
*/
export interface SparkDetectionResult {
found: boolean;
name?: string;
url?: string;
}
/**
* Detect Spark implant by reading NDEF from ISO 15693 tag
* Spark implants have a URI record pointing to vivokey.co/$code
*
* @param chipType - The detected chip type (used to determine Spark 1 vs 2)
*/
export async function detectSparkImplant(
chipType: ChipType,
): Promise<SparkDetectionResult> {
try {
console.log('[ISO15693] Checking for Spark implant NDEF...');
// Read first few blocks to find NDEF data
// ISO 15693 NDEF layout: Block 0 = CC (Capability Container), Blocks 1+ = NDEF message
const ndefBytes: number[] = [];
// Read blocks 0-7 (typically enough for a short NDEF URI)
for (let block = 0; block < 8; block++) {
try {
const response = await transceiveNfcV(iso15693ReadSingleBlock(block));
// Response format: [flags] [data...]
// Skip first byte (flags) if present
if (response.length > 1 && (response[0] & 0x01) === 0) {
// No error, data follows
ndefBytes.push(...response.slice(1));
} else if (response.length >= 4) {
// Some readers don't include flags
ndefBytes.push(...response);
}
} catch (e) {
console.log(`[ISO15693] Block ${block} read failed:`, e);
break;
}
}
if (ndefBytes.length === 0) {
console.log('[ISO15693] No NDEF data read');
return {found: false};
}
console.log(
'[ISO15693] Read NDEF bytes:',
ndefBytes.map(b => b.toString(16).padStart(2, '0')).join(' '),
);
// Convert to ASCII and look for vivokey.co pattern
const asciiStr = ndefBytes
.filter(b => b >= 0x20 && b <= 0x7e)
.map(b => String.fromCharCode(b))
.join('');
console.log('[ISO15693] ASCII content:', asciiStr);
// Check for vivokey.co URL pattern
const vivokeyMatch = asciiStr.match(/vivokey\.co\/([A-Za-z0-9]+)/i);
if (vivokeyMatch) {
const code = vivokeyMatch[1];
const url = `https://vivokey.co/${code}`;
console.log('[ISO15693] Found Spark URL:', url);
// Determine Spark version based on chip type
// SLIX = Spark 1, NTAG 424 DNA = Spark 2
let sparkName: string;
if (
chipType === ChipType.SLIX ||
chipType === ChipType.SLIX_S ||
chipType === ChipType.SLIX_L ||
chipType === ChipType.SLIX2
) {
sparkName = 'Spark 1';
} else if (chipType === ChipType.NTAG424_DNA) {
sparkName = 'Spark 2';
} else {
sparkName = 'Spark';
}
return {
found: true,
name: sparkName,
url,
};
}
console.log('[ISO15693] No vivokey.co URL found');
return {found: false};
} catch (error) {
console.warn('[ISO15693] Spark detection failed:', error);
return {found: false};
}
}

View File

@@ -116,59 +116,87 @@ export async function detectJavaCard(): Promise<JavaCardDetectionResult> {
// First, try to select the Card Manager (ISD) // First, try to select the Card Manager (ISD)
// Card Manager selection might fail, but we can still try CPLC // Card Manager selection might fail, but we can still try CPLC
// Some cards allow CPLC without selecting an applet // Some cards allow CPLC without selecting an applet
let cardManagerSelected = false;
try { try {
await sendIsoDepCommand(selectAid(KNOWN_AIDS.cardManager)); const cmResponse = await sendIsoDepCommand(
selectAid(KNOWN_AIDS.cardManager),
);
const cmParsed = parseApduResponse(cmResponse);
cardManagerSelected = cmParsed.isSuccess;
} catch { } catch {
// Ignore - some cards don't support Card Manager selection // Ignore - some cards don't support Card Manager selection
} }
// Get CPLC data // Get CPLC data
let cplc: CPLCData | null = null;
let fabricatorName: string | undefined;
let osName: string | undefined;
if (cardManagerSelected) {
try {
const cplcResponse = await sendIsoDepCommand(GET_CPLC); const cplcResponse = await sendIsoDepCommand(GET_CPLC);
const cplcParsed = parseApduResponse(cplcResponse); const cplcParsed = parseApduResponse(cplcResponse);
if (!cplcParsed.isSuccess || cplcParsed.data.length < 20) { if (cplcParsed.isSuccess && cplcParsed.data.length >= 20) {
// CPLC not available - might not be a JavaCard cplc = parseCPLC(cplcParsed.data);
return { if (cplc) {
success: false, fabricatorName =
error: 'CPLC data not available - may not be a JavaCard',
};
}
// Parse CPLC
const cplc = parseCPLC(cplcParsed.data);
if (!cplc) {
return {
success: false,
error: 'Failed to parse CPLC data',
};
}
// Identify fabricator
const fabricatorName =
IC_FABRICATORS[cplc.icFabricator] || IC_FABRICATORS[cplc.icFabricator] ||
`Unknown (0x${cplc.icFabricator.toString(16)})`; `Unknown (0x${cplc.icFabricator.toString(16)})`;
osName = identifyJcopVersion(cplc.osId) || undefined;
}
}
} catch {
// CPLC failed - will try AID probing
}
}
// Identify JCOP version // Probe for installed applets (this also helps identify DT implants)
const osName = identifyJcopVersion(cplc.osId); const installedApplets = await probeApplets();
// Determine chip type // Determine chip type
let chipType: ChipType = ChipType.JAVACARD_UNKNOWN; let chipType: ChipType = ChipType.JAVACARD_UNKNOWN;
// Check if it's NXP JCOP4 (J3R180) // If we have CPLC and it's NXP JCOP4
if (cplc.icFabricator === 0x4790 && osName?.includes('JCOP4')) { if (cplc && cplc.icFabricator === 0x4790 && osName?.includes('JCOP4')) {
chipType = ChipType.JCOP4; chipType = ChipType.JCOP4;
} }
// If we found JavaCard Memory, it's definitely a JCOP4 (Apex/flexSecure)
else if (installedApplets.includes('JavaCard Memory')) {
chipType = ChipType.JCOP4;
if (!osName) {
osName = 'JCOP4 (VivoKey)';
}
if (!fabricatorName) {
fabricatorName = 'NXP Semiconductors';
}
}
// If Fidesmo is present, likely JCOP4
else if (installedApplets.includes('Fidesmo') && !cplc) {
chipType = ChipType.JCOP4;
if (!osName) {
osName = 'JCOP4 (Fidesmo)';
}
}
// Probe for installed applets // If no identification was successful at all
const installedApplets = await probeApplets(); if (
chipType === ChipType.JAVACARD_UNKNOWN &&
!cplc &&
installedApplets.length === 0
) {
return {
success: false,
error: 'Could not identify JavaCard - CPLC unavailable and no known applets found',
};
}
return { return {
success: true, success: true,
chipType, chipType,
cplc, cplc: cplc || undefined,
fabricatorName, fabricatorName,
osName: osName || `Unknown OS (0x${cplc.osId.toString(16)})`, osName: osName || (cplc ? `Unknown OS (0x${cplc.osId.toString(16)})` : 'Unknown'),
installedApplets, installedApplets,
}; };
} catch (error) { } catch (error) {
@@ -188,6 +216,19 @@ export async function detectJavaCard(): Promise<JavaCardDetectionResult> {
async function probeApplets(): Promise<string[]> { async function probeApplets(): Promise<string[]> {
const found: string[] = []; const found: string[] = [];
// Try JavaCard Memory Manager (indicates Apex or flexSecure)
try {
const response = await sendIsoDepCommand(
selectAid(KNOWN_AIDS.javacardMemory),
);
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('JavaCard Memory');
}
} catch {
// Applet not present
}
// Try OpenPGP applet // Try OpenPGP applet
try { try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.openPgp)); const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.openPgp));
@@ -210,9 +251,115 @@ async function probeApplets(): Promise<string[]> {
// Applet not present // Applet not present
} }
// Try Fidesmo service discovery
try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.fidesmo));
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('Fidesmo');
}
} catch {
// Applet not present
}
// Try PPSE (Proximity Payment System Environment) - indicates contactless payment card
try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.ppse));
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('Payment (PPSE)');
// If PPSE is present, try to identify specific payment network
await probePaymentNetworks(found);
}
} catch {
// Not a payment card
}
return found; return found;
} }
/**
* Probe for specific payment network applets
* Only called if PPSE is present (i.e., this is a payment card)
*/
async function probePaymentNetworks(found: string[]): Promise<void> {
// Try Visa
try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.visaCredit));
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('Visa');
return; // Found payment network, no need to check others
}
} catch {
// Not present
}
// Try Mastercard
try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.mastercard));
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('Mastercard');
return;
}
} catch {
// Not present
}
// Try Amex
try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.amex));
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('American Express');
return;
}
} catch {
// Not present
}
// Try Discover
try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.discover));
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('Discover');
return;
}
} catch {
// Not present
}
// Try Maestro
try {
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.maestro));
const parsed = parseApduResponse(response);
if (parsed.isSuccess) {
found.push('Maestro');
return;
}
} catch {
// Not present
}
}
/**
* Check if the JavaCard Memory Manager applet is present
* This indicates an Apex or flexSecure implant
*/
export async function hasJavacardMemory(): Promise<boolean> {
try {
const response = await sendIsoDepCommand(
selectAid(KNOWN_AIDS.javacardMemory),
);
const parsed = parseApduResponse(response);
return parsed.isSuccess;
} catch {
return false;
}
}
/** /**
* Check if tag might be a JavaCard based on ATS/historical bytes * Check if tag might be a JavaCard based on ATS/historical bytes
*/ */

View File

@@ -5,7 +5,7 @@
*/ */
import {ChipType, NtagVersionInfo} from '../../types/detection'; import {ChipType, NtagVersionInfo} from '../../types/detection';
import {NTAG_GET_VERSION, sendType2Command} from '../nfc/commands'; import {NTAG_GET_VERSION, sendType2Command, ntagRead} from '../nfc/commands';
/** /**
* GET_VERSION response structure (same for NTAG and Ultralight): * GET_VERSION response structure (same for NTAG and Ultralight):
@@ -303,3 +303,133 @@ export function formatNtagVersionInfo(info: NtagVersionInfo): string {
`Storage: 0x${info.storageSize.toString(16)}`, `Storage: 0x${info.storageSize.toString(16)}`,
].join(', '); ].join(', ');
} }
/**
* Known Dangerous Things implant names that may be written to Type 2 tag memory
* Looking for 4+ character partial matches (case-insensitive)
* Note: Only includes Type 2 (NTAG/Ultralight) based implants
*/
const KNOWN_IMPLANT_NAMES = [
// X-Series NTAG implants
'xNT',
'xSIID',
'NExT',
// Bioresin NTAG implants
'dNExT',
// Flex NTAG implants
'flexNT',
// Generic identifiers
'VivoKey',
'Dangerous',
'DNGRTHNG',
'DNGR',
];
/**
* Memory layout for NTAG chips
* Last user page = total pages - config pages - 1
*/
const NTAG_MEMORY_LAYOUT: Partial<Record<ChipType, {totalPages: number; lastUserPage: number}>> = {
[ChipType.NTAG213]: {totalPages: 45, lastUserPage: 39},
[ChipType.NTAG215]: {totalPages: 135, lastUserPage: 129},
[ChipType.NTAG216]: {totalPages: 231, lastUserPage: 225},
[ChipType.NTAG_I2C_1K]: {totalPages: 231, lastUserPage: 225},
[ChipType.NTAG_I2C_2K]: {totalPages: 485, lastUserPage: 479},
[ChipType.NTAG_I2C_PLUS_1K]: {totalPages: 231, lastUserPage: 225},
[ChipType.NTAG_I2C_PLUS_2K]: {totalPages: 485, lastUserPage: 479},
};
/**
* Convert bytes to ASCII string, filtering non-printable characters
*/
function bytesToAscii(bytes: number[]): string {
return bytes
.filter(b => b >= 0x20 && b <= 0x7e) // Printable ASCII only
.map(b => String.fromCharCode(b))
.join('');
}
/**
* Result of implant name detection
*/
export interface ImplantNameResult {
found: boolean;
name?: string;
rawBytes?: number[];
}
/**
* Check memory pages for implant name (4+ character partial match)
* Reads the last 16 bytes (4 pages) of user memory where implant names are typically stored
*/
export async function detectImplantNameInMemory(
chipType: ChipType,
): Promise<ImplantNameResult> {
const layout = NTAG_MEMORY_LAYOUT[chipType];
if (!layout) {
console.log(`[NTAG] No memory layout for chip type: ${chipType}`);
return {found: false};
}
try {
// Read last 4 pages (16 bytes) - where implant names are typically stored
// For NTAG216, this is pages 222-225 (lastUserPage - 3 to lastUserPage)
const startPage = layout.lastUserPage - 3;
console.log(
`[NTAG] Reading pages ${startPage}-${layout.lastUserPage} (0x${startPage.toString(16)}-0x${layout.lastUserPage.toString(16)}) for implant name`,
);
// READ command returns 4 pages (16 bytes) starting at the given page
const response = await sendType2Command(ntagRead(startPage));
console.log(
`[NTAG] Read ${response.length} bytes:`,
response.map(b => b.toString(16).padStart(2, '0')).join(' '),
);
const asciiStr = bytesToAscii(response);
console.log(`[NTAG] ASCII: "${asciiStr}"`);
// Look for 4+ character matches (case-insensitive)
const upperAscii = asciiStr.toUpperCase();
for (const name of KNOWN_IMPLANT_NAMES) {
if (name.length >= 4 && upperAscii.includes(name.toUpperCase())) {
console.log(`[NTAG] Found implant name: ${name}`);
return {
found: true,
name,
rawBytes: response,
};
}
}
// If not found in last 4 pages, try the 4 pages before that
const earlierStartPage = Math.max(4, startPage - 4);
if (earlierStartPage < startPage) {
console.log(
`[NTAG] Trying earlier pages ${earlierStartPage}-${earlierStartPage + 3}`,
);
const earlierResponse = await sendType2Command(ntagRead(earlierStartPage));
const earlierAscii = bytesToAscii(earlierResponse);
const upperEarlierAscii = earlierAscii.toUpperCase();
for (const name of KNOWN_IMPLANT_NAMES) {
if (name.length >= 4 && upperEarlierAscii.includes(name.toUpperCase())) {
console.log(`[NTAG] Found implant name in earlier pages: ${name}`);
return {
found: true,
name,
rawBytes: earlierResponse,
};
}
}
}
console.log('[NTAG] No implant name found in memory');
return {found: false, rawBytes: response};
} catch (error) {
console.warn('[NTAG] Failed to read memory for implant name:', error);
return {found: false};
}
}

View File

@@ -6,6 +6,8 @@
import {Platform} from 'react-native'; import {Platform} from 'react-native';
import NfcManager, {NfcTech} from 'react-native-nfc-manager'; import NfcManager, {NfcTech} from 'react-native-nfc-manager';
export {NfcTech};
/** /**
* APDU response with status word * APDU response with status word
*/ */
@@ -130,6 +132,20 @@ export function iso15693ReadSingleBlock(blockNumber: number): number[] {
return [0x02, 0x20, blockNumber]; return [0x02, 0x20, blockNumber];
} }
/**
* ISO 15693 READ_MULTIPLE_BLOCKS command (0x23)
* Reads multiple consecutive blocks
*/
export function iso15693ReadMultipleBlocks(
startBlock: number,
numBlocks: number,
): number[] {
// Flags 0x02: unaddressed mode
// Command 0x23: READ_MULTIPLE_BLOCKS
// numBlocks is 0-indexed (0 = 1 block, so subtract 1)
return [0x02, 0x23, startBlock, numBlocks - 1];
}
// ============================================================================ // ============================================================================
// Known AIDs // Known AIDs
// ============================================================================ // ============================================================================
@@ -141,6 +157,42 @@ export const KNOWN_AIDS = {
openPgp: [0xd2, 0x76, 0x00, 0x01, 0x24, 0x01], openPgp: [0xd2, 0x76, 0x00, 0x01, 0x24, 0x01],
/** FIDO/U2F applet */ /** FIDO/U2F applet */
fido: [0xa0, 0x00, 0x00, 0x06, 0x47, 0x2f, 0x00, 0x01], fido: [0xa0, 0x00, 0x00, 0x06, 0x47, 0x2f, 0x00, 0x01],
/** FIDO2/WebAuthn applet (CTAP2) */
fido2: [0xa0, 0x00, 0x00, 0x06, 0x47, 0x2f, 0x00, 0x01],
/** Fidesmo service discovery applet */
fidesmo: [0xa0, 0x00, 0x00, 0x06, 0x17, 0x00],
/** NFC Forum Type 4 Tag NDEF applet */
ndefTag: [0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01],
/** VivoKey OTP applet */
vivokeyOtp: [0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01],
/** PIV (Personal Identity Verification) */
piv: [0xa0, 0x00, 0x00, 0x03, 0x08],
/** OATH (OTP) applet */
oath: [0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01],
/** JavaCard Memory Manager - present on Apex and flexSecure implants */
javacardMemory: [
0xa0, 0x00, 0x00, 0x08, 0x46, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x01,
],
// Payment network AIDs (EMV)
/** Visa Credit/Debit */
visaCredit: [0xa0, 0x00, 0x00, 0x00, 0x03, 0x10, 0x10],
/** Visa Electron */
visaElectron: [0xa0, 0x00, 0x00, 0x00, 0x03, 0x20, 0x10],
/** Mastercard Credit/Debit */
mastercard: [0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10],
/** Mastercard Maestro */
maestro: [0xa0, 0x00, 0x00, 0x00, 0x04, 0x30, 0x60],
/** American Express */
amex: [0xa0, 0x00, 0x00, 0x00, 0x25, 0x01, 0x08, 0x01],
/** Discover */
discover: [0xa0, 0x00, 0x00, 0x01, 0x52, 0x30, 0x10],
/** JCB */
jcb: [0xa0, 0x00, 0x00, 0x00, 0x65, 0x10, 0x10],
/** UnionPay */
unionpay: [0xa0, 0x00, 0x00, 0x03, 0x33, 0x01, 0x01, 0x01],
/** PPSE (Proximity Payment System Environment) - present on all contactless payment cards */
ppse: [0x32, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31], // "2PAY.SYS.DDF01"
}; };
// ============================================================================ // ============================================================================

View File

@@ -190,6 +190,9 @@ export interface Transponder {
/** SAK swap detection results */ /** SAK swap detection results */
sakSwapInfo?: SakSwapInfo; sakSwapInfo?: SakSwapInfo;
/** Implant name found in memory (for Type 2 tags) */
implantName?: string;
/** Detection confidence level */ /** Detection confidence level */
confidence: 'high' | 'medium' | 'low'; confidence: 'high' | 'medium' | 'low';

35
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Logger utility that only outputs in development mode
* All console.log statements in the app should use this logger
*/
/* eslint-disable no-console */
const isDev = __DEV__;
export const logger = {
log: (...args: unknown[]) => {
if (isDev) {
console.log(...args);
}
},
warn: (...args: unknown[]) => {
if (isDev) {
console.warn(...args);
}
},
error: (...args: unknown[]) => {
// Always log errors, even in production
console.error(...args);
},
debug: (...args: unknown[]) => {
if (isDev) {
console.log('[DEBUG]', ...args);
}
},
};
export default logger;