Initial commit

This commit is contained in:
michael
2026-01-22 13:55:55 -08:00
commit d05c02f556
31 changed files with 17832 additions and 0 deletions

4
.eslintrc.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: '@react-native',
};

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native directories (generated by expo prebuild)
android/
ios/
# Env files
.env
.env.local
.env.*.local
# Editor
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# TypeScript
*.tsbuildinfo
# Testing
coverage/
# Build
*.jsbundle

77
App.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React from 'react';
import {StatusBar} from 'react-native';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import {PaperProvider} from 'react-native-paper';
import {NavigationContainer, DefaultTheme} from '@react-navigation/native';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
import {DTTheme, DTColors} from './src/theme';
import {HomeScreen, ScanScreen, ResultScreen} from './src/screens';
import type {RootStackParamList} from './src/types/navigation';
const Stack = createNativeStackNavigator<RootStackParamList>();
const NavigationTheme = {
...DefaultTheme,
dark: true,
colors: {
...DefaultTheme.colors,
primary: DTColors.modeNormal,
background: DTColors.dark,
card: DTColors.dark,
text: DTColors.light,
border: DTColors.modeNormal,
notification: DTColors.modeEmphasis,
},
};
function App() {
return (
<SafeAreaProvider>
<PaperProvider theme={DTTheme}>
<StatusBar barStyle="light-content" backgroundColor={DTColors.dark} />
<NavigationContainer theme={NavigationTheme}>
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerStyle: {
backgroundColor: DTColors.dark,
},
headerTintColor: DTColors.modeNormal,
headerTitleStyle: {
fontWeight: '600',
},
contentStyle: {
backgroundColor: DTColors.dark,
},
animation: 'slide_from_right',
}}>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{headerShown: false}}
/>
<Stack.Screen
name="Scan"
component={ScanScreen}
options={{
title: 'SCAN',
headerBackTitle: 'Back',
}}
/>
<Stack.Screen
name="Result"
component={ResultScreen}
options={{
title: 'RESULT',
headerBackTitle: 'Scan',
}}
/>
</Stack.Navigator>
</NavigationContainer>
</PaperProvider>
</SafeAreaProvider>
);
}
export default App;

362
CLAUDE.md Normal file
View File

@@ -0,0 +1,362 @@
# CLAUDE.md - Project Intelligence for Dangerous Things NFC Identifier
## Project Overview
React Native app that scans NFC transponders and matches them to Dangerous Things implant products. Target users are prospective customers who want to know which implant can replace their existing cards/fobs.
## Critical Context
### NFC Detection Strategy
The app must identify transponders using a **waterfall detection approach**:
```
1. Read basic tag info (UID, ATQA, SAK for 14443-A)
2. Determine tag technology class
3. Run technology-specific identification:
- NTAG: Send GET_VERSION (0x60) command
- MIFARE Classic: SAK identifies 1K (0x08) vs 4K (0x18)
- ISO 14443-4: Parse ATS/historical bytes, then:
- DESFire: Check for DESFire ATS signature
- MIFARE Plus: Check for Plus ATS signature
- JavaCard: GET DATA for CPLC, probe known AIDs
- ISO 15693: Get system info for SLIX identification
```
### Platform Differences
**Android** - Full access via `NfcA`, `NfcB`, `IsoDep`, `NfcV`, `MifareClassic` tech classes. Can send raw commands.
**iOS** - CoreNFC with `NFCISO7816Tag` and `NFCISO15693Tag`. Provides:
- `historicalBytes` (from ATS) - use for card platform identification
- `identifier` (UID)
- APDU commands via `sendCommand()`
- NO access to MIFARE Classic sectors (no crypto support)
**iOS MIFARE Classic limitation**: iOS cannot read MIFARE Classic as a distinct technology. It appears as an ISO 14443-3A tag. Detection relies on SAK value (0x08 or 0x18) but sector-level operations are Android-only.
### JavaCard (J3R180) Detection
For JCOP4/J3R180 identification:
1. Check `historicalBytes` for JCOP signatures (`4A434F50` = "JCOP")
2. GET DATA for CPLC: `80 CA 9F 7F 00`
3. Parse CPLC for IC fabricator (NXP = 4790), card type, OS ID
4. Probe AIDs for installed applets (user may provide DT-specific AIDs)
### Product Matching Logic
```typescript
interface Transponder {
type: ChipType;
subtype?: string; // e.g., "NTAG215" vs "NTAG216"
memorySize?: number;
isCloneable: boolean;
rawData: {
uid: string;
sak?: number;
atqa?: string;
ats?: string;
historicalBytes?: string;
cplc?: CPLCData;
};
}
interface Product {
id: string;
name: string;
compatibleChips: ChipType[];
description: string;
features: string[];
}
```
**Cloneable chips**:
- NTAG213/215/216 (data can be written to compatible implant)
- MIFARE Classic 1K/4K (requires key knowledge; Android only for sector ops)
- SLIX/SLIX2 (data can be written)
**Non-cloneable**: DESFire, MIFARE Plus, JavaCard (crypto prevents duplication)
When no match found, direct to: `dngr.us/conversion`
## Design System
Base theme on `/home/work/WebstormProjects/dt-shopify-storefront/app/styles/app.css`
### Colors (CSS variables → RN)
```typescript
const DTColors = {
dark: '#000000',
light: '#FFFFFF',
modeNormal: '#00FFFF', // Cyan - primary actions
modeNormalSelected: 'rgba(0, 255, 255, 0.7)',
modeEmphasis: '#FFFF00', // Yellow - highlights
modeEmphasisSelected: 'rgba(255, 255, 0, 0.7)',
modeWarning: '#FF0000', // Red - errors/warnings
modeSuccess: '#00FF00', // Green - success states
modeOther: '#FF00FF', // Magenta - misc
};
```
### Typography
- Primary font: "Tektur" (variable font, weights 100-900)
- Fallback: System default
### UI Patterns
- **Beveled corners**: Use `clip-path` equivalent or custom shapes
- **Cards**: Black background, colored borders, beveled bottom-right
- **Buttons**: Outlined with mode color, filled on hover/press
- **Emphasis**: Yellow for important actions, cyan for standard
## Tech Stack Decisions
| Concern | Choice | Rationale |
|---------|--------|-----------|
| NFC | react-native-nfc-manager | Industry standard, good iOS/Android support |
| UI | React Native Paper | Material Design 3, easy theming |
| State | React Context | Simple app, no complex state needs |
| Navigation | React Navigation | Standard choice |
| Types | TypeScript strict | Prevent NFC data handling bugs |
## File Organization
```
src/
├── App.tsx # Entry point, providers
├── components/
│ ├── ui/ # Themed RNP components
│ │ ├── DTCard.tsx
│ │ ├── DTButton.tsx
│ │ └── DTChip.tsx
│ ├── scan/ # Scan-related components
│ │ ├── ScanButton.tsx
│ │ └── ScanAnimation.tsx
│ └── results/ # Result display components
│ ├── TransponderInfo.tsx
│ ├── ProductMatch.tsx
│ └── NoMatchFound.tsx
├── screens/
│ ├── HomeScreen.tsx
│ ├── ScanScreen.tsx
│ └── ResultScreen.tsx
├── services/
│ ├── nfc/
│ │ ├── NFCManager.ts # Wrapper around react-native-nfc-manager
│ │ ├── commands.ts # APDU command builders
│ │ └── platforms.ts # Platform-specific logic
│ ├── detection/
│ │ ├── detector.ts # Main detection orchestrator
│ │ ├── ntag.ts # NTAG identification
│ │ ├── mifare.ts # MIFARE Classic/Plus/DESFire
│ │ ├── iso15693.ts # SLIX detection
│ │ └── javacard.ts # J3R180/JCOP detection
│ └── matching/
│ └── matcher.ts # Product matching logic
├── data/
│ ├── products.ts # Hardcoded product catalog
│ └── chipProfiles.ts # Chip identification signatures
├── theme/
│ ├── colors.ts
│ ├── typography.ts
│ └── paperTheme.ts # RNP theme configuration
├── types/
│ ├── nfc.ts # NFC-related types
│ ├── products.ts # Product types
│ └── detection.ts # Detection result types
├── hooks/
│ ├── useNFC.ts
│ └── useScan.ts
└── utils/
├── hex.ts # Hex string utilities
├── apdu.ts # APDU parsing utilities
└── platform.ts # Platform detection
```
## Development Phases (Context-Optimized)
### Phase 1: Foundation (Single Session)
**Goal**: Bootable app with theme and navigation
- Initialize RN project with TypeScript
- Install dependencies (RNP, react-native-nfc-manager, navigation)
- Create theme system from DT colors
- Set up basic navigation (Home → Scan → Results)
- Create placeholder screens
**Deliverable**: App runs on both platforms with DT styling
### Phase 2: NFC Infrastructure (Single Session)
**Goal**: NFC scanning works on both platforms
- Implement NFCManager wrapper
- Handle permissions (Android manifest, iOS entitlements)
- Create useScan hook with states (idle, scanning, success, error)
- Test basic tag detection (just read UID)
**Deliverable**: App can detect NFC tag presence
### Phase 3: Chip Detection - Basic (Single Session)
**Goal**: Identify common chip types
- Implement NTAG detection (GET_VERSION)
- Implement MIFARE Classic detection (SAK-based)
- Create detection result types
- Display raw detection results
**Deliverable**: App identifies NTAG and MIFARE Classic
### Phase 4: Chip Detection - Advanced (Single Session)
**Goal**: Full chip identification suite
- Implement ISO 14443-4 detection (DESFire, Plus)
- Implement ISO 15693 detection (SLIX)
- Implement JavaCard detection (CPLC + AID probing)
- Handle platform differences gracefully
**Deliverable**: App identifies all target chip types
### Phase 5: Product Matching (Single Session)
**Goal**: Match transponders to products
- Create product data structure
- Populate with DT product catalog
- Implement matching algorithm
- Create result UI components
- Add "no match" handling with conversion link
**Deliverable**: Full transponder-to-implant flow complete
### Phase 6: Polish (Single Session)
**Goal**: Production-ready UX
- Add scan animations
- Implement error handling UI
- Add educational chip info
- Test edge cases
- Performance optimization
**Deliverable**: App ready for release
## Common Patterns
### APDU Command Structure
```typescript
// Command APDU: CLA INS P1 P2 [Lc] [Data] [Le]
const GET_CPLC = [0x80, 0xCA, 0x9F, 0x7F, 0x00];
const SELECT_AID = (aid: number[]) => [0x00, 0xA4, 0x04, 0x00, aid.length, ...aid, 0x00];
// DESFire GET_VERSION (ISO-wrapped for cross-platform support)
// Response byte 3 = HW major: 0x00=EV0, 0x01=EV1, 0x10=EV2, 0x30=EV3
const DESFIRE_GET_VERSION = [0x90, 0x60, 0x00, 0x00, 0x00];
```
### Platform-Safe NFC Code
```typescript
import { Platform } from 'react-native';
import NfcManager, { NfcTech } from 'react-native-nfc-manager';
async function detectTag() {
if (Platform.OS === 'ios') {
// iOS: Use ISO 7816 for everything 14443-4
await NfcManager.requestTechnology(NfcTech.Iso7816);
} else {
// Android: Can be more specific
await NfcManager.requestTechnology([
NfcTech.IsoDep,
NfcTech.NfcA,
NfcTech.MifareClassic,
]);
}
}
```
### Error Boundaries for NFC
Always wrap NFC operations in try/catch. Common errors:
- Tag lost during read
- Unsupported tag type
- Permission denied
- NFC disabled on device
## Testing Notes
### Physical Test Cards Needed
- NTAG213, NTAG215, NTAG216
- MIFARE Classic 1K, 4K
- MIFARE DESFire EV1/EV2/EV3
- MIFARE Plus
- SLIX/SLIX2
- J3R180/JCOP4 card
### Simulator Limitations
NFC cannot be tested in simulators. Use physical devices only.
## Known Issues & Workarounds
1. **iOS MIFARE Classic**: Cannot do sector operations. Only detect via SAK, inform user of Android requirement for cloning.
2. **iOS Background Reading**: Not supported. App must be foregrounded.
3. **Android NFC Intent Handling**: May need to handle `onNewIntent` for tags scanned while app is open.
4. **react-native-nfc-manager quirks**: Always call `NfcManager.cancelTechnologyRequest()` in finally blocks.
## Reference Links
- [react-native-nfc-manager docs](https://github.com/revtel/react-native-nfc-manager)
- [NXP MIFARE product selector](https://www.nxp.com/products/rfid-nfc/mifare-hf:MIFARE)
- [ISO 14443 overview](https://www.iso.org/standard/73599.html)
- [JCOP CPLC parsing](https://www.openscdp.org/scripts/tutorial/emv/CPLC.html)
- [Dangerous Things conversion service](https://dngr.us/conversion)
## Commands
```bash
# Development
npm start # Start Metro bundler
npm run android # Run on Android
npm run ios # Run on iOS
# Type checking
npm run typecheck # Run TypeScript compiler
# Linting
npm run lint # ESLint
npm run lint:fix # ESLint with auto-fix
# Testing (when tests exist)
npm test # Run Jest tests
```
## Environment Setup Checklist
### Android
- [ ] `android/app/src/main/AndroidManifest.xml` has NFC permissions
- [ ] Min SDK 21+ (for robust NFC support)
- [ ] NFC intent filters configured
### iOS
- [ ] NFC capability added in Xcode
- [ ] `NFCReaderUsageDescription` in Info.plist
- [ ] `com.apple.developer.nfc.readersession.iso7816.select-identifiers` for AID selection
- [ ] `com.apple.developer.nfc.readersession.iso15693.select-identifiers` for SLIX
## AID Reference (For JavaCard Profiling)
Dangerous Things may provide specific AIDs for their applets. Common AIDs:
```typescript
const KNOWN_AIDS = {
// Global Platform Card Manager
cardManager: [0xA0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00],
// VivoKey-specific (placeholder - get actual AIDs from DT)
// vivoKeyAuth: [...],
// vivoKeyOTP: [...],
// Standard applets
openPGP: [0xD2, 0x76, 0x00, 0x01, 0x24, 0x01],
fido: [0xA0, 0x00, 0x00, 0x06, 0x47, 0x2F, 0x00, 0x01],
};
```
## Conversation Starters for Future Sessions
When resuming work, start with:
1. "Continue from Phase N of the roadmap"
2. "The current blocker is..."
3. "Need to implement [specific detector/component]"
Always check `package.json` and run `npm install` if dependencies seem missing.

16
Gemfile Normal file
View File

@@ -0,0 +1,16 @@
source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 has removed some libraries from the standard library.
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
# Getting Started
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
## Step 1: Start Metro
First, you will need to run **Metro**, the JavaScript build tool for React Native.
To start the Metro dev server, run the following command from the root of your React Native project:
```sh
# Using npm
npm start
# OR using Yarn
yarn start
```
## Step 2: Build and run your app
With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app:
### Android
```sh
# Using npm
npm run android
# OR using Yarn
yarn android
```
### iOS
For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps).
The first time you create a new project, run the Ruby bundler to install CocoaPods itself:
```sh
bundle install
```
Then, and every time you update your native dependencies, run:
```sh
bundle exec pod install
```
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
```sh
# Using npm
npm run ios
# OR using Yarn
yarn ios
```
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
This is one way to run your app — you can also build it directly from Android Studio or Xcode.
## Step 3: Modify your app
Now that you have successfully run the app, let's make changes!
Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
- **iOS**: Press <kbd>R</kbd> in iOS Simulator.
## Congratulations! :tada:
You've successfully run and modified your React Native App. :partying_face:
### Now what?
- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
# Troubleshooting
If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
# Learn More
To learn more about React Native, take a look at the following resources:
- [React Native Website](https://reactnative.dev) - learn more about React Native.
- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.

307
ROADMAP.md Normal file
View File

@@ -0,0 +1,307 @@
# Development Roadmap
This roadmap is optimized for AI-assisted development with context window efficiency in mind. Each phase is designed to be **completable in a single session** without requiring extensive context from previous phases.
## Principles
1. **Self-contained phases** - Each phase has clear inputs/outputs
2. **Minimal cross-phase dependencies** - Read file structure, not conversation history
3. **Testable milestones** - Each phase ends with something verifiable
4. **Progressive complexity** - Simple foundations before complex detection
---
## Phase 1: Project Bootstrap
**Session prompt**: "Initialize the React Native project with TypeScript, React Native Paper, and the Dangerous Things theme system."
### Inputs
- None (greenfield)
### Tasks
1. Initialize Expo project with TypeScript template
2. Install core dependencies:
- `@anthropic-dangerous-things/react-native-theme` (local link or npm)
- `react-native-paper`
- `react-native-safe-area-context`
- `@react-navigation/native` + `@react-navigation/native-stack`
- `react-native-nfc-manager`
- `expo-font` (for Tektur)
3. Link the DT theme package (`../react-native-dt-theme`)
4. Use DTThemeProvider from the theme package
- `colors.ts` - DT color palette
- `typography.ts` - Tektur font setup
- `paperTheme.ts` - React Native Paper theme config
4. Create basic navigation structure:
- `HomeScreen` - Mode selection (just "Transponder to Implant" for now)
- `ScanScreen` - Placeholder
- `ResultScreen` - Placeholder
5. Wrap app with providers (Paper, Navigation, SafeArea)
### Outputs
- Runnable app on both platforms
- DT-themed UI with working navigation
- No NFC functionality yet
### Verification
```bash
npm run android # App launches with cyan/yellow theme
npm run ios # App launches with cyan/yellow theme
```
---
## Phase 2: NFC Infrastructure
**Session prompt**: "Implement the NFC service layer with platform permissions and the useScan hook."
### Inputs
- Phase 1 complete (check for `src/theme/paperTheme.ts`)
### Tasks
1. Configure Android NFC permissions:
- `AndroidManifest.xml` - NFC permission + intent filters
- Min SDK verification (21+)
2. Configure iOS NFC entitlements:
- Add NFC capability in Xcode
- `Info.plist` - Usage description + AID identifiers
3. Create NFC service layer (`src/services/nfc/`):
- `NFCManager.ts` - Wrapper with init/cleanup lifecycle
- `platforms.ts` - Platform detection utilities
4. Create scan hook (`src/hooks/useScan.ts`):
- States: `idle`, `scanning`, `success`, `error`
- Handle tag discovered callback
- Proper cleanup on unmount
5. Update `ScanScreen` to use hook and display raw tag data
### Outputs
- NFC permissions working on both platforms
- Can detect any NFC tag and show UID
- Proper scan lifecycle management
### Verification
- Scan any NFC tag → UID displayed
- Cancel scan → returns to idle state
- Error states shown for NFC disabled/permission denied
---
## Phase 3: Basic Chip Detection
**Session prompt**: "Implement NTAG and MIFARE Classic detection with the chip identification framework."
### Inputs
- Phase 2 complete (check for `src/hooks/useScan.ts`)
### Tasks
1. Define detection types (`src/types/detection.ts`):
```typescript
interface DetectionResult {
chipType: ChipType;
chipSubtype?: string;
isCloneable: boolean;
confidence: 'high' | 'medium' | 'low';
rawData: RawTagData;
}
```
2. Create detection framework (`src/services/detection/`):
- `detector.ts` - Main orchestrator
- `types.ts` - Chip type enums
3. Implement NTAG detection (`src/services/detection/ntag.ts`):
- Send GET_VERSION (0x60)
- Parse response for 213/215/216/I2C variants
- Mark as cloneable
4. Implement MIFARE Classic detection (`src/services/detection/mifare.ts`):
- Check SAK: 0x08 (1K), 0x18 (4K), 0x09 (Mini)
- Mark as cloneable
- Note Android-only for sector operations
5. Update `ScanScreen` to show detection results
### Outputs
- NTAG213/215/216 correctly identified
- MIFARE Classic 1K/4K correctly identified
- Detection results displayed with chip info
### Verification
- Scan NTAG215 → Shows "NTAG215, Cloneable: Yes"
- Scan MIFARE Classic 1K → Shows "MIFARE Classic 1K, Cloneable: Yes"
- Scan other tags → Shows "Unknown" with raw data
---
## Phase 4: Advanced Chip Detection
**Session prompt**: "Add detection for DESFire, MIFARE Plus, SLIX, and JavaCard chips."
### Inputs
- Phase 3 complete (check for `src/services/detection/ntag.ts`)
### Tasks
1. Implement ISO 14443-4 detection (`src/services/detection/iso14443.ts`):
- Parse ATS/historical bytes
- Route to specific detectors based on signatures
2. Implement DESFire detection (`src/services/detection/desfire.ts`):
- Identify via ATS signature
- Send GET_VERSION for EV1/EV2/EV3 determination
- Mark as non-cloneable
3. Implement MIFARE Plus detection:
- Security level detection if possible
- Mark as non-cloneable
4. Implement ISO 15693 detection (`src/services/detection/iso15693.ts`):
- Get system info command
- Identify SLIX/SLIX2 variants
- Mark as cloneable
5. Implement JavaCard detection (`src/services/detection/javacard.ts`):
- Parse historical bytes for JCOP signature
- GET DATA for CPLC
- AID probing for installed applets
- Mark as non-cloneable
6. Handle platform differences gracefully:
- iOS: Use ISO 7816 wrapped commands
- Android: Use native tech classes
### Outputs
- All target chip types detected
- DESFire version (EV1/2/3) identified
- JavaCard identification via CPLC
- Platform-appropriate detection paths
### Verification
- Scan DESFire EV2 → Shows "MIFARE DESFire EV2, Cloneable: No"
- Scan SLIX → Shows "SLIX, Cloneable: Yes"
- Scan J3R180 → Shows "JCOP4 (J3R180), Cloneable: No"
---
## Phase 5: Product Matching
**Session prompt**: "Create the product catalog and matching algorithm, then build the results UI."
### Inputs
- Phase 4 complete (check for `src/services/detection/javacard.ts`)
### Tasks
1. Define product types (`src/types/products.ts`):
```typescript
interface Product {
id: string;
name: string;
compatibleChips: ChipType[];
description: string;
features: string[];
formFactor: 'x-series' | 'flex' | 'other';
}
```
2. Create product catalog (`src/data/products.ts`):
- xNT (NTAG216)
- xEM (T5577 - for EM/HID cloning)
- NExT (NTAG216 + T5577)
- xSIID (NTAG I2C + LED)
- xDF2 (DESFire EV2)
- flexDF2 (DESFire EV2 flex)
- xMagic (MIFARE Classic + Gen2 Magic)
- Apex (J3R180)
- Apex Flex (J3R180 flex)
- SLIX implants if available
3. Implement matching algorithm (`src/services/matching/matcher.ts`):
- Match by chip type
- Rank by feature parity
- Handle cloneable vs native matches
4. Create result components (`src/components/results/`):
- `TransponderInfo.tsx` - Detected chip details
- `ProductMatch.tsx` - Matching product card
- `NoMatchFound.tsx` - Conversion service link
5. Update `ResultScreen` with full flow
### Outputs
- Complete transponder-to-implant matching
- Product cards with descriptions
- "No match" redirects to dngr.us/conversion
### Verification
- Scan NTAG215 → Shows xNT, NExT as matches
- Scan MIFARE Classic → Shows xMagic as match
- Scan unknown chip → Shows conversion link
---
## Phase 6: Polish & UX
**Session prompt**: "Add animations, error handling, and final UI polish for production readiness."
### Inputs
- Phase 5 complete (check for `src/services/matching/matcher.ts`)
### Tasks
1. Add scan animations:
- Pulsing scan indicator
- Success/failure transitions
- Card detection feedback
2. Implement comprehensive error handling:
- NFC disabled state
- Permission denied flow
- Tag lost during scan
- Unsupported tag graceful fallback
3. Add educational content:
- Chip type explanations
- Cloneability explanations
- "Why can't this be cloned?" info
4. UI refinements:
- Beveled corner components (DT style)
- Proper loading states
- Empty states
5. Platform testing:
- Test all chip types on Android
- Test all chip types on iOS (within CoreNFC limits)
- Edge case handling
### Outputs
- Production-ready UX
- Helpful error messages
- Educational chip information
- Consistent DT branding throughout
### Verification
- Full flow feels polished
- Errors are helpful, not cryptic
- Works offline
- Matches DT website aesthetic
---
## Future Phases (Post-MVP)
### Phase 7: "ID an Implant" Mode
*Details TBD with employer*
### Phase 8: Scan History
- Local storage of past scans
- Favorites/bookmarks
### Phase 9: Store Integration
- Direct product links (when store is live)
- Pricing display
---
## Session Recovery Guide
If starting a new session mid-project:
1. **Check current state**:
```bash
ls src/services/detection/ # Which detectors exist?
ls src/data/ # Product data present?
```
2. **Identify phase**:
- No `src/` → Start Phase 1
- No `useScan.ts` → Start Phase 2
- No `ntag.ts` → Start Phase 3
- No `javacard.ts` → Start Phase 4
- No `products.ts` → Start Phase 5
- All present → Phase 6 or done
3. **Read CLAUDE.md** for technical context
4. **Use phase-specific prompt** from this document

13
__tests__/App.test.tsx Normal file
View File

@@ -0,0 +1,13 @@
/**
* @format
*/
import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import App from '../App';
test('renders correctly', async () => {
await ReactTestRenderer.act(() => {
ReactTestRenderer.create(<App />);
});
});

42
app.json Normal file
View File

@@ -0,0 +1,42 @@
{
"expo": {
"name": "DT NFC Identifier",
"slug": "dt-nfc-identifier",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"backgroundColor": "#000000"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.dangerousthings.nfcidentifier",
"infoPlist": {
"NFCReaderUsageDescription": "This app uses NFC to scan transponders and identify compatible Dangerous Things implants."
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#000000"
},
"package": "com.dangerousthings.nfcidentifier",
"permissions": ["android.permission.NFC"]
},
"plugins": [
[
"react-native-nfc-manager",
{
"nfcPermission": "This app uses NFC to scan transponders and identify compatible Dangerous Things implants.",
"selectIdentifiers": [
"A0000000030000",
"D27600012401",
"A0000006472F0001"
],
"systemCodes": []
}
]
]
}
}

6
babel.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

4
index.js Normal file
View File

@@ -0,0 +1,4 @@
import {registerRootComponent} from 'expo';
import App from './App';
registerRootComponent(App);

3
jest.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
preset: 'react-native',
};

5
metro.config.js Normal file
View File

@@ -0,0 +1,5 @@
const {getDefaultConfig} = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
module.exports = config;

15199
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "dt-nfc-identifier",
"version": "1.0.0",
"main": "index.js",
"private": true,
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
"prebuild:clean": "expo prebuild --clean",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@react-navigation/native": "^7.1.27",
"@react-navigation/native-stack": "^7.9.1",
"babel-preset-expo": "^54.0.9",
"expo": "^54.0.31",
"expo-dev-client": "^6.0.20",
"expo-status-bar": "^3.0.9",
"react": "19.2.0",
"react-native": "0.83.1",
"react-native-nfc-manager": "^3.17.2",
"react-native-paper": "^5.14.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-vector-icons": "^10.2.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native/babel-preset": "0.83.1",
"@react-native/eslint-config": "0.83.1",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
"react-test-renderer": "19.2.0",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=20"
}
}

2
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {useScan} from './useScan';
export type {UseScanResult} from './useScan';

186
src/hooks/useScan.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* useScan Hook
* React hook for managing NFC scan state and operations
*/
import {useState, useCallback, useEffect, useRef} from 'react';
import {nfcManager} from '../services/nfc';
import type {ScanState, RawTagData, ScanError, NFCStatus} from '../types/nfc';
export interface UseScanResult {
/** Current scan state */
state: ScanState;
/** Scanned tag data (when state is 'success') */
tag: RawTagData | null;
/** Scan error (when state is 'error') */
error: ScanError | null;
/** NFC status (supported and enabled) */
nfcStatus: NFCStatus;
/** Start scanning for a tag */
startScan: () => Promise<void>;
/** Cancel ongoing scan */
cancelScan: () => Promise<void>;
/** Reset state to idle */
reset: () => void;
/** Open device NFC settings */
openSettings: () => Promise<void>;
}
/**
* Hook for managing NFC scanning
*/
export function useScan(): UseScanResult {
const [state, setState] = useState<ScanState>('idle');
const [tag, setTag] = useState<RawTagData | null>(null);
const [error, setError] = useState<ScanError | null>(null);
const [nfcStatus, setNfcStatus] = useState<NFCStatus>({
isSupported: false,
isEnabled: false,
});
// Track if component is mounted to prevent state updates after unmount
const isMounted = useRef(true);
// Track if a scan is in progress
const scanInProgress = useRef(false);
// Initialize NFC and check status
useEffect(() => {
let mounted = true;
async function initialize() {
await nfcManager.init();
if (mounted) {
const status = await nfcManager.getStatus();
setNfcStatus(status);
}
}
initialize();
return () => {
mounted = false;
isMounted.current = false;
// Cleanup on unmount
nfcManager.cancelScan();
};
}, []);
// Start scanning for a tag
const startScan = useCallback(async () => {
// Prevent multiple concurrent scans
if (scanInProgress.current) {
return;
}
scanInProgress.current = true;
setState('scanning');
setTag(null);
setError(null);
try {
// Check NFC status first
const status = await nfcManager.getStatus();
if (isMounted.current) {
setNfcStatus(status);
}
if (!status.isSupported) {
if (isMounted.current) {
setState('error');
setError({
type: 'NFC_NOT_SUPPORTED',
message: 'NFC is not supported on this device',
});
}
return;
}
if (!status.isEnabled) {
if (isMounted.current) {
setState('error');
setError({
type: 'NFC_NOT_ENABLED',
message: 'Please enable NFC in your device settings',
});
}
return;
}
// Perform the scan
const result = await nfcManager.scanTag();
if (!isMounted.current) {
return;
}
if (result.error) {
// Don't show error for cancellation
if (result.error.type === 'SCAN_CANCELLED') {
setState('idle');
} else {
setState('error');
setError(result.error);
}
} else if (result.tag) {
setState('success');
setTag(result.tag);
} else {
setState('error');
setError({
type: 'UNKNOWN',
message: 'No tag data received',
});
}
} catch (err) {
if (isMounted.current) {
setState('error');
setError({
type: 'UNKNOWN',
message: err instanceof Error ? err.message : 'An unknown error occurred',
originalError: err,
});
}
} finally {
scanInProgress.current = false;
}
}, []);
// Cancel ongoing scan
const cancelScan = useCallback(async () => {
await nfcManager.cancelScan();
scanInProgress.current = false;
if (isMounted.current) {
setState('idle');
setError(null);
}
}, []);
// Reset state to idle
const reset = useCallback(() => {
setState('idle');
setTag(null);
setError(null);
}, []);
// Open device NFC settings
const openSettings = useCallback(async () => {
await nfcManager.openNFCSettings();
// Re-check status after returning from settings
const status = await nfcManager.getStatus();
if (isMounted.current) {
setNfcStatus(status);
}
}, []);
return {
state,
tag,
error,
nfcStatus,
startScan,
cancelScan,
reset,
openSettings,
};
}

100
src/screens/HomeScreen.tsx Normal file
View File

@@ -0,0 +1,100 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import {Button, Text, Surface} from 'react-native-paper';
import type {HomeScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
export function HomeScreen({navigation}: HomeScreenProps) {
return (
<View style={styles.container}>
<Surface style={styles.header} elevation={0}>
<Text variant="displaySmall" style={styles.title}>
DANGEROUS THINGS
</Text>
<Text variant="headlineSmall" style={styles.subtitle}>
NFC IDENTIFIER
</Text>
</Surface>
<View style={styles.content}>
<Text variant="bodyLarge" style={styles.description}>
Scan any NFC transponder to find compatible Dangerous Things implants.
</Text>
<Button
mode="outlined"
onPress={() => navigation.navigate('Scan')}
style={styles.scanButton}
labelStyle={styles.scanButtonLabel}
contentStyle={styles.scanButtonContent}>
START SCAN
</Button>
</View>
<View style={styles.footer}>
<Text variant="bodySmall" style={styles.footerText}>
dngr.us
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: DTColors.dark,
padding: 24,
},
header: {
alignItems: 'center',
paddingTop: 60,
paddingBottom: 40,
backgroundColor: 'transparent',
},
title: {
color: DTColors.modeNormal,
fontWeight: '700',
letterSpacing: 2,
},
subtitle: {
color: DTColors.modeEmphasis,
marginTop: 8,
letterSpacing: 4,
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
description: {
color: DTColors.light,
textAlign: 'center',
marginBottom: 48,
opacity: 0.9,
paddingHorizontal: 20,
},
scanButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
borderRadius: 4,
},
scanButtonLabel: {
color: DTColors.modeNormal,
fontSize: 18,
fontWeight: '600',
letterSpacing: 2,
},
scanButtonContent: {
paddingVertical: 12,
paddingHorizontal: 32,
},
footer: {
alignItems: 'center',
paddingBottom: 20,
},
footerText: {
color: DTColors.modeNormal,
opacity: 0.6,
},
});

View File

@@ -0,0 +1,240 @@
import React from 'react';
import {StyleSheet, View, ScrollView, Linking} from 'react-native';
import {Button, Text, Surface, Divider} from 'react-native-paper';
import type {ResultScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
export function ResultScreen({route, navigation}: ResultScreenProps) {
const {tagData} = route.params;
const handleConversionLink = () => {
Linking.openURL('https://dngr.us/conversion');
};
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
<Surface style={styles.resultCard} elevation={1}>
<Text variant="labelLarge" style={styles.cardLabel}>
TAG DETECTED
</Text>
<Divider style={styles.divider} />
{tagData ? (
<>
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
UID
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.uid || 'N/A'}
</Text>
</View>
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
TECHNOLOGIES
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.techTypes.join(', ') || 'N/A'}
</Text>
</View>
{tagData.sak !== undefined && (
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
SAK
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
0x{tagData.sak.toString(16).toUpperCase().padStart(2, '0')}
</Text>
</View>
)}
{tagData.atqa && (
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
ATQA
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.atqa}
</Text>
</View>
)}
{tagData.historicalBytes && (
<View style={styles.dataRow}>
<Text variant="bodyMedium" style={styles.dataLabel}>
HISTORICAL BYTES
</Text>
<Text variant="bodyLarge" style={styles.dataValue}>
{tagData.historicalBytes}
</Text>
</View>
)}
</>
) : (
<Text variant="bodyLarge" style={styles.noData}>
No tag data available
</Text>
)}
</Surface>
<Surface style={styles.matchCard} elevation={1}>
<Text variant="labelLarge" style={styles.matchLabel}>
COMPATIBLE IMPLANTS
</Text>
<Divider style={styles.divider} />
<Text variant="bodyLarge" style={styles.placeholderText}>
Detection coming in Phase 3-5
</Text>
<Text variant="bodyMedium" style={styles.hintText}>
Full chip identification and product matching will be implemented in
later phases.
</Text>
</Surface>
<Surface style={styles.conversionCard} elevation={1}>
<Text variant="bodyMedium" style={styles.conversionText}>
Can't find a match? Check out our conversion service.
</Text>
<Button
mode="outlined"
onPress={handleConversionLink}
style={styles.conversionButton}
labelStyle={styles.conversionButtonLabel}>
CONVERSION SERVICE
</Button>
</Surface>
<View style={styles.actions}>
<Button
mode="outlined"
onPress={() => navigation.navigate('Scan')}
style={styles.actionButton}
labelStyle={styles.actionButtonLabel}>
SCAN ANOTHER
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Home')}
labelStyle={styles.homeLabel}>
HOME
</Button>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: DTColors.dark,
},
content: {
padding: 24,
},
resultCard: {
backgroundColor: '#0a0a0a',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeNormal,
padding: 20,
marginBottom: 20,
},
cardLabel: {
color: DTColors.modeNormal,
letterSpacing: 2,
marginBottom: 12,
},
divider: {
backgroundColor: DTColors.modeNormal,
opacity: 0.3,
marginBottom: 16,
},
dataRow: {
marginBottom: 16,
},
dataLabel: {
color: DTColors.light,
opacity: 0.6,
marginBottom: 4,
letterSpacing: 1,
},
dataValue: {
color: DTColors.light,
fontFamily: 'monospace',
},
noData: {
color: DTColors.light,
opacity: 0.6,
fontStyle: 'italic',
},
matchCard: {
backgroundColor: '#0a0a0a',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeEmphasis,
padding: 20,
marginBottom: 20,
},
matchLabel: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
marginBottom: 12,
},
placeholderText: {
color: DTColors.light,
opacity: 0.8,
marginBottom: 8,
},
hintText: {
color: DTColors.light,
opacity: 0.5,
fontStyle: 'italic',
},
conversionCard: {
backgroundColor: '#0a0a0a',
borderRadius: 4,
borderWidth: 1,
borderColor: DTColors.modeOther,
padding: 20,
marginBottom: 32,
alignItems: 'center',
},
conversionText: {
color: DTColors.light,
textAlign: 'center',
marginBottom: 16,
opacity: 0.9,
},
conversionButton: {
borderColor: DTColors.modeOther,
borderWidth: 2,
},
conversionButtonLabel: {
color: DTColors.modeOther,
letterSpacing: 1,
},
actions: {
alignItems: 'center',
gap: 16,
},
actionButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
width: '100%',
},
actionButtonLabel: {
color: DTColors.modeNormal,
letterSpacing: 2,
},
homeLabel: {
color: DTColors.light,
opacity: 0.6,
},
});

312
src/screens/ScanScreen.tsx Normal file
View File

@@ -0,0 +1,312 @@
import React, {useCallback, useEffect} from 'react';
import {StyleSheet, View, Platform} from 'react-native';
import {Button, Text, ActivityIndicator} from 'react-native-paper';
import type {ScanScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
import {useScan} from '../hooks';
import {getScanInstructions} from '../services/nfc';
export function ScanScreen({navigation}: ScanScreenProps) {
const {
state,
tag,
error,
nfcStatus,
startScan,
cancelScan,
openSettings,
} = useScan();
// Navigate to results when scan succeeds
useEffect(() => {
if (state === 'success' && tag) {
navigation.replace('Result', {
tagData: {
uid: tag.uid,
techTypes: tag.techTypes,
sak: tag.sak,
atqa: tag.atqa,
ats: tag.ats,
historicalBytes: tag.historicalBytes,
},
});
}
}, [state, tag, navigation]);
// Auto-start scan when screen loads
useEffect(() => {
// Small delay to ensure screen is mounted
const timeout = setTimeout(() => {
startScan();
}, 300);
return () => clearTimeout(timeout);
// Only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCancel = useCallback(async () => {
await cancelScan();
navigation.goBack();
}, [cancelScan, navigation]);
const handleRetry = useCallback(() => {
startScan();
}, [startScan]);
// Render NFC not supported state
if (!nfcStatus.isSupported && state !== 'scanning') {
return (
<View style={styles.container}>
<View style={styles.content}>
<Text variant="headlineMedium" style={styles.errorText}>
NFC NOT SUPPORTED
</Text>
<Text variant="bodyLarge" style={styles.errorMessage}>
This device does not support NFC scanning.
</Text>
</View>
<View style={styles.footer}>
<Button
mode="text"
onPress={() => navigation.goBack()}
labelStyle={styles.cancelLabel}>
GO BACK
</Button>
</View>
</View>
);
}
// Render NFC disabled state
if (!nfcStatus.isEnabled && error?.type === 'NFC_NOT_ENABLED') {
return (
<View style={styles.container}>
<View style={styles.content}>
<Text variant="headlineMedium" style={styles.warningText}>
NFC DISABLED
</Text>
<Text variant="bodyLarge" style={styles.errorMessage}>
Please enable NFC in your device settings to scan tags.
</Text>
{Platform.OS === 'android' && (
<Button
mode="outlined"
onPress={openSettings}
style={styles.settingsButton}
labelStyle={styles.buttonLabel}>
OPEN SETTINGS
</Button>
)}
</View>
<View style={styles.footer}>
<Button
mode="text"
onPress={handleRetry}
labelStyle={styles.retryLabel}>
TRY AGAIN
</Button>
<Button
mode="text"
onPress={() => navigation.goBack()}
labelStyle={styles.cancelLabel}>
CANCEL
</Button>
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.content}>
{state === 'scanning' && (
<>
<View style={styles.scanIndicator}>
<ActivityIndicator
size="large"
color={DTColors.modeNormal}
style={styles.spinner}
/>
<View style={styles.pulseRing} />
</View>
<Text variant="headlineMedium" style={styles.scanningText}>
SCANNING
</Text>
<Text variant="bodyLarge" style={styles.instructionText}>
{getScanInstructions()}
</Text>
</>
)}
{state === 'error' && (
<>
<Text variant="headlineMedium" style={styles.errorText}>
SCAN FAILED
</Text>
<Text variant="bodyLarge" style={styles.errorMessage}>
{error?.message || 'Unable to read NFC tag'}
</Text>
{error?.type === 'TAG_LOST' && (
<Text variant="bodyMedium" style={styles.hintText}>
Hold the tag steady until the scan completes
</Text>
)}
<Button
mode="outlined"
onPress={handleRetry}
style={styles.retryButton}
labelStyle={styles.buttonLabel}>
TRY AGAIN
</Button>
</>
)}
{state === 'idle' && (
<>
<Text variant="headlineMedium" style={styles.readyText}>
READY TO SCAN
</Text>
<Button
mode="outlined"
onPress={startScan}
style={styles.startButton}
labelStyle={styles.buttonLabel}>
START
</Button>
</>
)}
{state === 'processing' && (
<>
<ActivityIndicator
size="large"
color={DTColors.modeEmphasis}
style={styles.processingSpinner}
/>
<Text variant="headlineMedium" style={styles.processingText}>
PROCESSING
</Text>
</>
)}
</View>
<View style={styles.footer}>
<Button
mode="text"
onPress={handleCancel}
labelStyle={styles.cancelLabel}>
CANCEL
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: DTColors.dark,
padding: 24,
},
content: {
flex: 1,
justifyContent: '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: {
color: DTColors.modeNormal,
letterSpacing: 4,
marginBottom: 16,
},
instructionText: {
color: DTColors.light,
opacity: 0.8,
textAlign: 'center',
paddingHorizontal: 20,
},
errorText: {
color: DTColors.modeWarning,
letterSpacing: 2,
marginBottom: 16,
},
warningText: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
marginBottom: 16,
},
errorMessage: {
color: DTColors.light,
opacity: 0.8,
textAlign: 'center',
marginBottom: 24,
paddingHorizontal: 20,
},
hintText: {
color: DTColors.modeNormal,
opacity: 0.7,
textAlign: 'center',
marginBottom: 24,
paddingHorizontal: 20,
fontStyle: 'italic',
},
retryButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
},
settingsButton: {
borderColor: DTColors.modeEmphasis,
borderWidth: 2,
marginTop: 16,
},
readyText: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
marginBottom: 32,
},
startButton: {
borderColor: DTColors.modeNormal,
borderWidth: 2,
},
buttonLabel: {
color: DTColors.modeNormal,
letterSpacing: 2,
},
processingSpinner: {
marginBottom: 24,
},
processingText: {
color: DTColors.modeEmphasis,
letterSpacing: 4,
},
footer: {
alignItems: 'center',
paddingBottom: 20,
},
cancelLabel: {
color: DTColors.light,
opacity: 0.6,
},
retryLabel: {
color: DTColors.modeNormal,
marginBottom: 8,
},
});

3
src/screens/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export {HomeScreen} from './HomeScreen';
export {ScanScreen} from './ScanScreen';
export {ResultScreen} from './ResultScreen';

View File

@@ -0,0 +1,295 @@
/**
* NFC Manager Service
* Wrapper around react-native-nfc-manager for cross-platform NFC operations
*/
import {Platform} from 'react-native';
import NfcManager, {NfcTech, TagEvent} from 'react-native-nfc-manager';
import type {
RawTagData,
NFCStatus,
ScanError,
ScanErrorType,
NfcTechType,
} from '../../types/nfc';
/**
* Convert byte array to hex string
*/
function bytesToHex(bytes: number[] | undefined): string {
if (!bytes || bytes.length === 0) {
return '';
}
return bytes.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':');
}
/**
* Parse SAK from tag event
*/
function parseSak(tag: TagEvent): number | undefined {
// Android provides nfcA.sak
const nfcA = (tag as any).nfcA;
if (nfcA?.sak !== undefined) {
return nfcA.sak;
}
return undefined;
}
/**
* Parse ATQA from tag event
*/
function parseAtqa(tag: TagEvent): string | undefined {
// Android provides nfcA.atqa as byte array
const nfcA = (tag as any).nfcA;
if (nfcA?.atqa) {
return bytesToHex(nfcA.atqa);
}
return undefined;
}
/**
* Parse ATS and historical bytes from tag event
*/
function parseAts(tag: TagEvent): {ats?: string; historicalBytes?: string} {
const isoDep = (tag as any).isoDep;
const iso7816 = (tag as any).iso7816;
let historicalBytes: string | undefined;
// Android: isoDep.historicalBytes
if (isoDep?.historicalBytes) {
historicalBytes = bytesToHex(isoDep.historicalBytes);
}
// iOS: iso7816.historicalBytes
if (iso7816?.historicalBytes) {
historicalBytes = bytesToHex(iso7816.historicalBytes);
}
// Construct ATS if we have historical bytes (simplified)
const ats = historicalBytes ? historicalBytes : undefined;
return {ats, historicalBytes};
}
/**
* Convert TagEvent to RawTagData
*/
function tagEventToRawData(tag: TagEvent): RawTagData {
const techTypes = (tag.techTypes || []) as NfcTechType[];
const uid = tag.id ? bytesToHex(tag.id as unknown as number[]) : '';
const sak = parseSak(tag);
const atqa = parseAtqa(tag);
const {ats, historicalBytes} = parseAts(tag);
const isoDep = (tag as any).isoDep;
const maxTransceiveLength = isoDep?.maxTransceiveLength;
return {
uid,
techTypes,
sak,
atqa,
ats,
historicalBytes,
maxTransceiveLength,
};
}
/**
* Create a structured scan error
*/
function createScanError(
type: ScanErrorType,
message: string,
originalError?: unknown,
): ScanError {
return {type, message, originalError};
}
/**
* Determine error type from error message/object
*/
function categorizeError(error: unknown): ScanError {
const errorMessage = error instanceof Error ? error.message : String(error);
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes('cancelled') || lowerMessage.includes('canceled')) {
return createScanError('SCAN_CANCELLED', 'Scan was cancelled', error);
}
if (lowerMessage.includes('tag was lost') || lowerMessage.includes('taglost')) {
return createScanError('TAG_LOST', 'Tag was removed during scan', error);
}
if (lowerMessage.includes('timeout')) {
return createScanError('TIMEOUT', 'Scan timed out', error);
}
if (lowerMessage.includes('permission')) {
return createScanError('PERMISSION_DENIED', 'NFC permission denied', error);
}
if (lowerMessage.includes('not enabled') || lowerMessage.includes('disabled')) {
return createScanError('NFC_NOT_ENABLED', 'NFC is disabled on this device', error);
}
if (lowerMessage.includes('not supported')) {
return createScanError('NFC_NOT_SUPPORTED', 'NFC is not supported on this device', error);
}
return createScanError('UNKNOWN', errorMessage || 'An unknown NFC error occurred', error);
}
/**
* NFC Manager singleton class
*/
class NFCManagerService {
private initialized = false;
/**
* Initialize NFC manager
* Must be called before any other NFC operations
*/
async init(): Promise<boolean> {
if (this.initialized) {
return true;
}
try {
const supported = await NfcManager.isSupported();
if (!supported) {
return false;
}
await NfcManager.start();
this.initialized = true;
return true;
} catch (error) {
console.error('[NFCManager] Init failed:', error);
return false;
}
}
/**
* Check NFC status (supported and enabled)
*/
async getStatus(): Promise<NFCStatus> {
try {
const isSupported = await NfcManager.isSupported();
if (!isSupported) {
return {isSupported: false, isEnabled: false};
}
const isEnabled = await NfcManager.isEnabled();
return {isSupported, isEnabled};
} catch {
return {isSupported: false, isEnabled: false};
}
}
/**
* Request NFC technology and scan for a tag
* Returns raw tag data on success
*/
async scanTag(): Promise<{tag?: RawTagData; error?: ScanError}> {
try {
// Ensure initialized
if (!this.initialized) {
const initSuccess = await this.init();
if (!initSuccess) {
return {
error: createScanError(
'NFC_NOT_SUPPORTED',
'NFC is not supported on this device',
),
};
}
}
// Check if NFC is enabled
const status = await this.getStatus();
if (!status.isEnabled) {
return {
error: createScanError(
'NFC_NOT_ENABLED',
'Please enable NFC in your device settings',
),
};
}
// Request technology based on platform
if (Platform.OS === 'ios') {
// iOS: Use MifareIOS for broadest compatibility with ISO 14443 tags
await NfcManager.requestTechnology(NfcTech.MifareIOS, {
alertMessage: 'Hold your NFC tag near the top of your iPhone',
});
} else {
// Android: Request multiple technologies for best detection
await NfcManager.requestTechnology([
NfcTech.NfcA,
NfcTech.NfcB,
NfcTech.NfcV,
NfcTech.IsoDep,
NfcTech.MifareClassic,
]);
}
// Get the tag
const tag = await NfcManager.getTag();
if (!tag) {
return {
error: createScanError('UNKNOWN', 'No tag data received'),
};
}
return {tag: tagEventToRawData(tag)};
} catch (error) {
return {error: categorizeError(error)};
} finally {
// Always clean up
await this.cancelScan();
}
}
/**
* Cancel ongoing scan and release NFC technology
*/
async cancelScan(): Promise<void> {
try {
await NfcManager.cancelTechnologyRequest();
} catch {
// Ignore errors during cleanup
}
}
/**
* Clean up NFC manager
* Call when app is unmounting or NFC no longer needed
*/
async cleanup(): Promise<void> {
try {
await this.cancelScan();
// Note: We don't call NfcManager.stop() as it can cause issues
// if we need to restart scanning
} catch {
// Ignore cleanup errors
}
}
/**
* Open device NFC settings (Android only)
*/
async openNFCSettings(): Promise<void> {
if (Platform.OS === 'android') {
try {
await NfcManager.goToNfcSetting();
} catch {
// Settings may not be available
}
}
}
}
// Export singleton instance
export const nfcManager = new NFCManagerService();

View File

@@ -0,0 +1,2 @@
export {nfcManager} from './NFCManager';
export * from './platforms';

View File

@@ -0,0 +1,147 @@
/**
* Platform-specific NFC utilities
*/
import {Platform} from 'react-native';
import type {NfcTechType, RawTagData} from '../../types/nfc';
/**
* Check if the current platform is iOS
*/
export function isIOS(): boolean {
return Platform.OS === 'ios';
}
/**
* Check if the current platform is Android
*/
export function isAndroid(): boolean {
return Platform.OS === 'android';
}
/**
* Check if MIFARE Classic sector operations are available
* Only supported on Android
*/
export function supportsMifareClassicSectors(): boolean {
return isAndroid();
}
/**
* Check if raw NFC-A commands are available
* Full support on Android, limited on iOS
*/
export function supportsRawNfcACommands(): boolean {
return isAndroid();
}
/**
* Check if the tag supports ISO-DEP (ISO 14443-4)
*/
export function hasIsoDep(tag: RawTagData): boolean {
return tag.techTypes.includes('IsoDep') || tag.techTypes.includes('Iso7816');
}
/**
* Check if the tag is a MIFARE Classic (based on SAK)
* SAK 0x08 = MIFARE Classic 1K
* SAK 0x18 = MIFARE Classic 4K
* SAK 0x09 = MIFARE Mini
*/
export function isMifareClassicByTag(tag: RawTagData): boolean {
if (tag.sak === undefined) {
return false;
}
return tag.sak === 0x08 || tag.sak === 0x18 || tag.sak === 0x09;
}
/**
* Get the MIFARE Classic size from SAK
*/
export function getMifareClassicSize(sak: number): '320B' | '1K' | '4K' | null {
switch (sak) {
case 0x09:
return '320B'; // Mini
case 0x08:
return '1K';
case 0x18:
return '4K';
default:
return null;
}
}
/**
* Check if tag has NFC-A technology (ISO 14443-3A)
*/
export function hasNfcA(tag: RawTagData): boolean {
return tag.techTypes.includes('NfcA');
}
/**
* Check if tag has NFC-V technology (ISO 15693)
*/
export function hasNfcV(tag: RawTagData): boolean {
return tag.techTypes.includes('NfcV') || tag.techTypes.includes('Iso15693');
}
/**
* Get platform-specific user instructions for scanning
*/
export function getScanInstructions(): string {
if (isIOS()) {
return 'Hold your NFC tag near the top of your iPhone';
}
return 'Hold your NFC tag near the back of your device';
}
/**
* Get the primary NFC tech type to request based on detected technologies
*/
export function getPrimaryTech(techTypes: NfcTechType[]): NfcTechType | null {
// Prefer IsoDep/Iso7816 for smart cards
if (techTypes.includes('IsoDep')) {
return 'IsoDep';
}
if (techTypes.includes('Iso7816')) {
return 'Iso7816';
}
// Then NFC-A for most common tags
if (techTypes.includes('NfcA')) {
return 'NfcA';
}
// NFC-V for ISO 15693 tags
if (techTypes.includes('NfcV')) {
return 'NfcV';
}
// MIFARE specific
if (techTypes.includes('MifareClassic')) {
return 'MifareClassic';
}
if (techTypes.includes('MifareUltralight')) {
return 'MifareUltralight';
}
return techTypes[0] || null;
}
/**
* Platform limitations info
*/
export const PLATFORM_LIMITATIONS = {
ios: {
mifareClassicSectors: false,
rawNfcA: false,
backgroundReading: false,
description: 'iOS has limited NFC capabilities due to CoreNFC restrictions',
},
android: {
mifareClassicSectors: true,
rawNfcA: true,
backgroundReading: true,
description: 'Android has full NFC capabilities',
},
};

13
src/theme/colors.ts Normal file
View File

@@ -0,0 +1,13 @@
export const DTColors = {
dark: '#000000',
light: '#FFFFFF',
modeNormal: '#00FFFF', // Cyan - primary actions
modeNormalSelected: 'rgba(0, 255, 255, 0.7)',
modeEmphasis: '#FFFF00', // Yellow - highlights
modeEmphasisSelected: 'rgba(255, 255, 0, 0.7)',
modeWarning: '#FF0000', // Red - errors/warnings
modeSuccess: '#00FF00', // Green - success states
modeOther: '#FF00FF', // Magenta - misc
} as const;
export type DTColorKey = keyof typeof DTColors;

4
src/theme/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export {DTColors} from './colors';
export type {DTColorKey} from './colors';
export {DTFonts, DTTypography} from './typography';
export {DTTheme} from './paperTheme';

71
src/theme/paperTheme.ts Normal file
View File

@@ -0,0 +1,71 @@
import {MD3DarkTheme, configureFonts} from 'react-native-paper';
import type {MD3Theme} from 'react-native-paper';
import {DTColors} from './colors';
import {DTTypography} from './typography';
const fontConfig = {
displayLarge: DTTypography.displayLarge,
displayMedium: DTTypography.displayMedium,
displaySmall: DTTypography.displaySmall,
headlineLarge: DTTypography.headlineLarge,
headlineMedium: DTTypography.headlineMedium,
headlineSmall: DTTypography.headlineSmall,
titleLarge: DTTypography.titleLarge,
titleMedium: DTTypography.titleMedium,
titleSmall: DTTypography.titleSmall,
bodyLarge: DTTypography.bodyLarge,
bodyMedium: DTTypography.bodyMedium,
bodySmall: DTTypography.bodySmall,
labelLarge: DTTypography.labelLarge,
labelMedium: DTTypography.labelMedium,
labelSmall: DTTypography.labelSmall,
};
export const DTTheme: MD3Theme = {
...MD3DarkTheme,
dark: true,
colors: {
...MD3DarkTheme.colors,
primary: DTColors.modeNormal,
onPrimary: DTColors.dark,
primaryContainer: DTColors.modeNormalSelected,
onPrimaryContainer: DTColors.dark,
secondary: DTColors.modeEmphasis,
onSecondary: DTColors.dark,
secondaryContainer: DTColors.modeEmphasisSelected,
onSecondaryContainer: DTColors.dark,
tertiary: DTColors.modeOther,
onTertiary: DTColors.dark,
tertiaryContainer: 'rgba(255, 0, 255, 0.3)',
onTertiaryContainer: DTColors.light,
error: DTColors.modeWarning,
onError: DTColors.dark,
errorContainer: 'rgba(255, 0, 0, 0.3)',
onErrorContainer: DTColors.light,
background: DTColors.dark,
onBackground: DTColors.light,
surface: DTColors.dark,
onSurface: DTColors.light,
surfaceVariant: '#1a1a1a',
onSurfaceVariant: DTColors.light,
outline: DTColors.modeNormal,
outlineVariant: 'rgba(0, 255, 255, 0.3)',
inverseSurface: DTColors.light,
inverseOnSurface: DTColors.dark,
inversePrimary: '#008888',
elevation: {
level0: 'transparent',
level1: '#0a0a0a',
level2: '#121212',
level3: '#1a1a1a',
level4: '#1e1e1e',
level5: '#222222',
},
surfaceDisabled: 'rgba(255, 255, 255, 0.12)',
onSurfaceDisabled: 'rgba(255, 255, 255, 0.38)',
backdrop: 'rgba(0, 0, 0, 0.5)',
shadow: DTColors.dark,
scrim: DTColors.dark,
},
fonts: configureFonts({config: fontConfig}),
};

96
src/theme/typography.ts Normal file
View File

@@ -0,0 +1,96 @@
import {Platform} from 'react-native';
export const DTFonts = {
primary: Platform.select({
ios: 'Tektur',
android: 'Tektur',
default: 'System',
}),
} as const;
export const DTTypography = {
displayLarge: {
fontFamily: DTFonts.primary,
fontSize: 57,
fontWeight: '400' as const,
letterSpacing: -0.25,
},
displayMedium: {
fontFamily: DTFonts.primary,
fontSize: 45,
fontWeight: '400' as const,
},
displaySmall: {
fontFamily: DTFonts.primary,
fontSize: 36,
fontWeight: '400' as const,
},
headlineLarge: {
fontFamily: DTFonts.primary,
fontSize: 32,
fontWeight: '400' as const,
},
headlineMedium: {
fontFamily: DTFonts.primary,
fontSize: 28,
fontWeight: '400' as const,
},
headlineSmall: {
fontFamily: DTFonts.primary,
fontSize: 24,
fontWeight: '400' as const,
},
titleLarge: {
fontFamily: DTFonts.primary,
fontSize: 22,
fontWeight: '500' as const,
},
titleMedium: {
fontFamily: DTFonts.primary,
fontSize: 16,
fontWeight: '500' as const,
letterSpacing: 0.15,
},
titleSmall: {
fontFamily: DTFonts.primary,
fontSize: 14,
fontWeight: '500' as const,
letterSpacing: 0.1,
},
bodyLarge: {
fontFamily: DTFonts.primary,
fontSize: 16,
fontWeight: '400' as const,
letterSpacing: 0.5,
},
bodyMedium: {
fontFamily: DTFonts.primary,
fontSize: 14,
fontWeight: '400' as const,
letterSpacing: 0.25,
},
bodySmall: {
fontFamily: DTFonts.primary,
fontSize: 12,
fontWeight: '400' as const,
letterSpacing: 0.4,
},
labelLarge: {
fontFamily: DTFonts.primary,
fontSize: 14,
fontWeight: '500' as const,
letterSpacing: 0.1,
},
labelMedium: {
fontFamily: DTFonts.primary,
fontSize: 12,
fontWeight: '500' as const,
letterSpacing: 0.5,
},
labelSmall: {
fontFamily: DTFonts.primary,
fontSize: 11,
fontWeight: '500' as const,
letterSpacing: 0.5,
},
} as const;

32
src/types/navigation.ts Normal file
View File

@@ -0,0 +1,32 @@
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
import type {NfcTechType} from './nfc';
export type TagDataParam = {
uid: string;
techTypes: NfcTechType[];
sak?: number;
atqa?: string;
ats?: string;
historicalBytes?: string;
};
export type RootStackParamList = {
Home: undefined;
Scan: undefined;
Result: {
tagData?: TagDataParam;
};
};
export type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
export type ScanScreenProps = NativeStackScreenProps<RootStackParamList, 'Scan'>;
export type ResultScreenProps = NativeStackScreenProps<
RootStackParamList,
'Result'
>;
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

82
src/types/nfc.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* NFC-related type definitions
*/
/**
* Supported NFC technology types from react-native-nfc-manager
*/
export type NfcTechType =
| 'NfcA'
| 'NfcB'
| 'NfcF'
| 'NfcV'
| 'IsoDep'
| 'Ndef'
| 'NdefFormatable'
| 'MifareClassic'
| 'MifareUltralight'
| 'Iso7816'
| 'Iso15693';
/**
* Raw tag data from NFC scan
*/
export interface RawTagData {
/** Tag UID as hex string (colon-separated) */
uid: string;
/** Available NFC technologies on this tag */
techTypes: NfcTechType[];
/** ATQA bytes (ISO 14443-3A) - Android only */
atqa?: string;
/** SAK byte (ISO 14443-3A) */
sak?: number;
/** ATS bytes (ISO 14443-4) */
ats?: string;
/** Historical bytes from ATS */
historicalBytes?: string;
/** Maximum transceive length */
maxTransceiveLength?: number;
}
/**
* Scan state machine states
*/
export type ScanState = 'idle' | 'scanning' | 'processing' | 'success' | 'error';
/**
* Scan result with raw tag data
*/
export interface ScanResult {
success: boolean;
tag?: RawTagData;
error?: ScanError;
}
/**
* NFC scan error types
*/
export type ScanErrorType =
| 'NFC_NOT_SUPPORTED'
| 'NFC_NOT_ENABLED'
| 'PERMISSION_DENIED'
| 'TAG_LOST'
| 'SCAN_CANCELLED'
| 'TIMEOUT'
| 'UNKNOWN';
/**
* Structured scan error
*/
export interface ScanError {
type: ScanErrorType;
message: string;
originalError?: unknown;
}
/**
* NFC manager status
*/
export interface NFCStatus {
isSupported: boolean;
isEnabled: boolean;
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"types": ["jest"]
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
"exclude": ["node_modules", "android", "ios"]
}