Migrate from .NET MAUI to Expo + Convex
Complete rewrite of Scry using TypeScript stack:
- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)
Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support
Removes all .NET/MAUI code (src/, test/, tools/).
💘 Generated with Crush
Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
This commit is contained in:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
357
AGENTS.md
357
AGENTS.md
|
|
@ -2,13 +2,15 @@
|
|||
|
||||
## Overview
|
||||
|
||||
Scry is a Magic: The Gathering card scanner app built with .NET MAUI. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall.
|
||||
Scry is a Magic: The Gathering card scanner app built with Expo (React Native) and Convex. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall.
|
||||
|
||||
**Key components:**
|
||||
- Mobile scanning app (MAUI/Android)
|
||||
- Card recognition via perceptual hashing (not OCR)
|
||||
- SQLite database with pre-computed hashes
|
||||
- Scryfall API integration for card data
|
||||
**Tech Stack:**
|
||||
- **Frontend**: Expo/React Native with Expo Router (file-based routing)
|
||||
- **Backend**: Convex (serverless functions + real-time database)
|
||||
- **Image Processing**: React Native Skia, fast-opencv
|
||||
- **Camera**: Adaptive (expo-camera in dev, react-native-vision-camera in production)
|
||||
- **Auth**: Convex Auth with Zitadel OIDC (GDPR-compliant, no PII stored)
|
||||
- **Package Manager**: Bun (not npm/yarn)
|
||||
|
||||
## Build Commands
|
||||
|
||||
|
|
@ -16,55 +18,84 @@ Use `just` commands (defined in `.justfile`):
|
|||
|
||||
| Task | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Build project | `just build` | Builds Android debug |
|
||||
| Run tests | `just test` | Runs xUnit tests |
|
||||
| Generate card database | `just gen-db` | Downloads from Scryfall, computes hashes |
|
||||
| Publish app | `just publish` | Creates release APK |
|
||||
| Hot reload dev | `just dev` | Uses `dotnet watch` |
|
||||
| Start emulator | `just emu` | Virtual camera with Serra Angel |
|
||||
| Install to device | `just install` | Installs release APK |
|
||||
| Start dev server | `just dev` | Runs Convex + Expo together |
|
||||
| Expo only | `just start` | Just the Expo dev server |
|
||||
| Convex only | `just convex-dev` | Just the Convex dev server |
|
||||
| Run on Android | `just android` | Starts Android emulator |
|
||||
| Install deps | `just expo-install` | Runs `bun install` |
|
||||
| Migrate hashes | `just expo-migrate` | Migrate card hashes to Convex |
|
||||
| Type check | `just expo-typecheck` | TypeScript check |
|
||||
| Start emulator | `just emu` | Virtual camera (submodule) |
|
||||
|
||||
### Database Generator Options
|
||||
### Direct Bun Commands
|
||||
|
||||
```bash
|
||||
just gen-db # Default: 500 cards with test images
|
||||
dotnet run --project tools/DbGenerator -- -c 1000 # More cards
|
||||
dotnet run --project tools/DbGenerator -- --force # Rebuild from scratch
|
||||
dotnet run --project tools/DbGenerator -- --no-test-cards # Skip priority test cards
|
||||
bun install # Install dependencies
|
||||
bun run dev # Convex + Expo hot reload
|
||||
bun run dev:convex # Convex dev server only
|
||||
bun run dev:expo # Expo dev server only
|
||||
bun run android # Run on Android
|
||||
bun run migrate # Migrate hashes to Convex
|
||||
bun run typecheck # TypeScript check
|
||||
bunx convex dev # Convex CLI
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── Scry.App/ # MAUI mobile app (Android target)
|
||||
│ ├── Views/ # XAML pages (ScanPage, CollectionPage, etc.)
|
||||
│ ├── ViewModels/ # MVVM ViewModels using CommunityToolkit.Mvvm
|
||||
│ ├── Services/ # App-layer services (ICardRecognitionService, ICardRepository)
|
||||
│ ├── Converters/ # XAML value converters
|
||||
│ ├── Models/ # App-specific models (CollectionEntry)
|
||||
│ └── Resources/Raw/ # Bundled card_hashes.db
|
||||
│
|
||||
└── Scry.Core/ # Platform-independent core library
|
||||
├── Recognition/ # CardRecognitionService, RecognitionOptions
|
||||
├── Imaging/ # PerceptualHash, ImagePreprocessor, CardDetector
|
||||
├── Data/ # CardDatabase (SQLite)
|
||||
├── Models/ # Card, Oracle, Set, ScanResult
|
||||
└── Scryfall/ # ScryfallClient for API/bulk data
|
||||
app/ # Expo Router pages
|
||||
├── _layout.tsx # Root layout (Convex + HashCache providers)
|
||||
├── modal.tsx # Card details modal
|
||||
├── +not-found.tsx # 404 page
|
||||
└── (tabs)/ # Tab navigation group
|
||||
├── _layout.tsx # Tab bar layout
|
||||
├── index.tsx # Collection tab (home)
|
||||
├── scan.tsx # Camera scan tab
|
||||
└── settings.tsx # Settings tab
|
||||
|
||||
test/
|
||||
└── Scry.Tests/ # xUnit tests
|
||||
components/
|
||||
├── camera/ # Adaptive camera system
|
||||
│ ├── index.tsx # AdaptiveCamera wrapper
|
||||
│ ├── ExpoCamera.tsx # expo-camera (Expo Go)
|
||||
│ └── VisionCamera.tsx # react-native-vision-camera (production)
|
||||
└── *.tsx # Shared UI components
|
||||
|
||||
tools/
|
||||
└── DbGenerator/ # CLI tool to generate card_hashes.db
|
||||
convex/ # Backend (Convex functions + schema)
|
||||
├── schema.ts # Database schema
|
||||
├── auth.ts # Zitadel OIDC configuration
|
||||
├── http.ts # HTTP endpoints for auth
|
||||
├── cards.ts # Card queries/mutations
|
||||
├── collections.ts # User collection functions
|
||||
├── users.ts # User functions
|
||||
├── scanHistory.ts # Scan history functions
|
||||
└── _generated/ # Auto-generated types
|
||||
|
||||
TestImages/ # Test images organized by category
|
||||
├── reference_alpha/ # Alpha/Beta cards for testing
|
||||
├── single_cards/ # Individual card photos
|
||||
├── varying_quality/ # Different lighting/quality
|
||||
├── hands/ # Cards held in hand
|
||||
├── foil/ # Foil cards with glare
|
||||
└── ... # More categories
|
||||
lib/
|
||||
├── recognition/ # Card recognition pipeline
|
||||
│ ├── recognitionService.ts # Main recognition logic
|
||||
│ ├── cardDetector.ts # Edge detection, find card quad
|
||||
│ ├── perspectiveCorrection.ts # Warp to rectangle
|
||||
│ ├── clahe.ts # CLAHE lighting normalization
|
||||
│ ├── perceptualHash.ts # 192-bit color hash (24 bytes)
|
||||
│ ├── imageUtils.ts # Resize, rotate, grayscale
|
||||
│ ├── imageLoader.ts # Load/resize images
|
||||
│ └── skiaDecoder.ts # Decode images with Skia
|
||||
├── hooks/ # React hooks
|
||||
│ ├── useAuth.ts # OAuth flow with expo-auth-session
|
||||
│ ├── useCamera.ts # Adaptive camera permissions
|
||||
│ ├── useConvex.ts # Convex data hooks
|
||||
│ ├── useSync.ts # Hash sync hook
|
||||
│ └── useUserProfile.ts # Fetch profile from Zitadel
|
||||
├── context/ # React contexts
|
||||
│ └── HashCacheContext.tsx # In-memory hash cache
|
||||
└── db/ # Local database utilities
|
||||
├── localDatabase.ts # SQLite wrapper
|
||||
└── syncService.ts # Sync with Convex
|
||||
|
||||
scripts/
|
||||
└── migrate-hashes.ts # Migration script
|
||||
|
||||
TestImages/ # Test images (225 files)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
|
@ -76,114 +107,67 @@ Camera Image
|
|||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ CardDetector │ ← Edge detection, find card quad
|
||||
│ loadImageAsBase64 │ ← Resize to 480×640
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ decodeImageBase64 │ ← Skia decodes to RGBA pixels
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ detectCard │ ← Edge detection, find card quad
|
||||
│ (optional) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ PerspectiveCorrection│ ← Warp to rectangle
|
||||
│ warpPerspective │ ← Warp detected quad to rectangle
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ ImagePreprocessor │ ← CLAHE for lighting normalization
|
||||
│ (ApplyClahe) │
|
||||
│ applyCLAHE │ ← Lighting normalization
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ PerceptualHash │ ← Compute 192-bit color hash (24 bytes)
|
||||
│ (ComputeColorHash) │
|
||||
│ computeColorHash │ ← Compute 192-bit color hash (24 bytes)
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ CardRecognitionService│ ← Hamming distance match against DB
|
||||
│ recognizeCard │ ← Hamming distance match against cache
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Data Model
|
||||
### Data Model (Convex Schema)
|
||||
|
||||
Three-table schema mirroring Scryfall:
|
||||
|
||||
- **oracles** - Abstract game cards (one per unique card name)
|
||||
- **sets** - MTG sets with metadata
|
||||
- **cards** - Printings with perceptual hashes (one per unique artwork)
|
||||
|
||||
The `Card` model includes denormalized Oracle fields for convenience.
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Purpose |
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `CardRecognitionService` | Main recognition logic, caches DB, handles rotation matching |
|
||||
| `PerceptualHash` | DCT-based color hash (192-bit = 8 bytes × 3 RGB channels) |
|
||||
| `ImagePreprocessor` | CLAHE, resize, grayscale conversions |
|
||||
| `CardDetector` | Edge detection + contour analysis to find card boundaries |
|
||||
| `PerspectiveCorrection` | Warp detected quad to rectangle |
|
||||
| `CardDatabase` | SQLite wrapper with batch insert, queries |
|
||||
| `ScryfallClient` | Bulk data streaming, image downloads |
|
||||
| `users` | Minimal auth (no PII, GDPR compliant) |
|
||||
| `cards` | Card printings with 24-byte perceptual hashes |
|
||||
| `oracles` | Abstract game cards (one per unique card name) |
|
||||
| `sets` | MTG sets with metadata |
|
||||
| `collections` | User card collections |
|
||||
| `scanHistory` | Scan history with confidence scores |
|
||||
| `metadata` | Sync metadata |
|
||||
|
||||
## Code Conventions
|
||||
### GDPR Compliance
|
||||
|
||||
### General
|
||||
- Database stores **no user PII** - only auth subject ID
|
||||
- User profile (name, email, image) fetched from Zitadel userinfo endpoint on demand
|
||||
- Profile held in memory only, never persisted
|
||||
|
||||
- **Target**: .NET 10.0 (net10.0-android for app, net10.0 for Core/tools)
|
||||
- **Nullable**: Enabled everywhere (`<Nullable>enable</Nullable>`)
|
||||
- **Warnings as errors**: `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`
|
||||
- **Central package management**: Versions in `Directory.Packages.props`
|
||||
### Adaptive Camera System
|
||||
|
||||
### C# Style
|
||||
The app detects its runtime environment and uses the appropriate camera:
|
||||
|
||||
- Records for data models (`record Card`, `record ScanResult`)
|
||||
- `required` properties for non-nullable required fields
|
||||
- Extension methods for conversions (`ScryfallCard.ToCard()`)
|
||||
- Static classes for pure functions (`PerceptualHash`, `ImagePreprocessor`)
|
||||
- `using` declarations (not `using` blocks) for disposables
|
||||
- File-scoped namespaces
|
||||
- Primary constructors where appropriate
|
||||
- `CancellationToken` parameter on all async methods
|
||||
- **Expo Go** (`Constants.appOwnership === "expo"`): Uses `expo-camera`
|
||||
- **Production builds**: Uses `react-native-vision-camera`
|
||||
|
||||
### MVVM (App layer)
|
||||
|
||||
- `CommunityToolkit.Mvvm` for source generators
|
||||
- `[ObservableProperty]` attributes for bindable properties
|
||||
- `[RelayCommand]` for commands
|
||||
- ViewModels in `Scry.ViewModels` namespace
|
||||
|
||||
### Naming
|
||||
|
||||
- Services: `ICardRecognitionService`, `CardRecognitionService`
|
||||
- Database methods: `GetCardsWithHashAsync`, `InsertCardBatchAsync`
|
||||
- Hash methods: `ComputeColorHash`, `HammingDistance`
|
||||
- Test methods: `RecognizeAsync_ExactMatch_ReturnsSuccess`
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are in `test/Scry.Tests` using xUnit.
|
||||
|
||||
```bash
|
||||
just test # Run all tests
|
||||
dotnet test --filter "FullyQualifiedName~PerceptualHash" # Filter by name
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
|
||||
| Test Class | Tests |
|
||||
|------------|-------|
|
||||
| `PerceptualHashTests` | Hash computation, Hamming distance |
|
||||
| `CardRecognitionTests` | End-to-end recognition |
|
||||
| `CardDatabaseTests` | SQLite CRUD operations |
|
||||
| `ImagePreprocessorTests` | CLAHE, resize |
|
||||
| `RobustnessAnalysisTests` | Multiple image variations |
|
||||
|
||||
### Test Images
|
||||
|
||||
TestImages directory contains categorized reference images:
|
||||
- `reference_alpha/` - Alpha/Beta cards (matching DbGenerator priority cards)
|
||||
- `single_cards/` - Clean single card photos
|
||||
- `varying_quality/` - Different lighting/blur conditions
|
||||
Both expose the same `CameraHandle` interface with `takePhoto()`.
|
||||
|
||||
## Key Algorithms
|
||||
|
||||
|
|
@ -206,86 +190,99 @@ Applied in LAB color space to L channel only:
|
|||
- Clip limit prevents over-amplification of noise
|
||||
- Bilinear interpolation between tiles for smooth output
|
||||
|
||||
### Card Detection
|
||||
## Code Conventions
|
||||
|
||||
Pure SkiaSharp implementation:
|
||||
1. Grayscale → Gaussian blur → Canny edge detection
|
||||
2. Contour tracing via flood fill
|
||||
3. Douglas-Peucker simplification → Convex hull
|
||||
4. Find best quadrilateral matching MTG aspect ratio (88/63 ≈ 1.397)
|
||||
5. Order corners: top-left, top-right, bottom-right, bottom-left
|
||||
### General
|
||||
|
||||
## Debug Mode
|
||||
- **TypeScript**: Strict mode enabled
|
||||
- **Formatting**: Prettier defaults
|
||||
- **Package Manager**: Bun (never use npm/yarn)
|
||||
|
||||
Set `RecognitionOptions.DebugOutputDirectory` to save pipeline stages:
|
||||
- `01_input.png` - Original image
|
||||
- `02_detection.png` - Card detection visualization
|
||||
- `03_perspective_corrected.png` - Warped card
|
||||
- `05_clahe_*.png` - After CLAHE preprocessing
|
||||
### React/React Native
|
||||
|
||||
On Android: `/sdcard/Download/scry-debug` (pull with `adb pull`)
|
||||
- Functional components with hooks
|
||||
- Expo Router for navigation (file-based)
|
||||
- Convex hooks for data (`useQuery`, `useMutation`)
|
||||
|
||||
## Dependencies
|
||||
### Naming
|
||||
|
||||
### Core Library (Scry.Core)
|
||||
- Hooks: `useCardHashes`, `useCameraPermission`
|
||||
- Components: PascalCase (`AdaptiveCamera`, `ScanScreen`)
|
||||
- Files: camelCase for modules, PascalCase for components
|
||||
- Convex functions: camelCase (`cards.ts`, `getByScryfallId`)
|
||||
|
||||
- **SkiaSharp** - Image processing, DCT, edge detection
|
||||
- **Microsoft.Data.Sqlite** - SQLite database
|
||||
- **Microsoft.Extensions.Options** - Options pattern
|
||||
### Convex Backend
|
||||
|
||||
### App (Scry.App)
|
||||
- Queries are reactive and cached
|
||||
- Mutations are transactional
|
||||
- Use `v.` validators for all arguments
|
||||
- Index frequently queried fields
|
||||
|
||||
- **CommunityToolkit.Maui** - MAUI extensions
|
||||
- **CommunityToolkit.Maui.Camera** - Camera integration
|
||||
- **CommunityToolkit.Mvvm** - MVVM source generators
|
||||
## Environment Variables
|
||||
|
||||
### DbGenerator Tool
|
||||
### Convex Backend (`convex/.env.local`)
|
||||
|
||||
- **Spectre.Console** / **Spectre.Console.Cli** - Rich terminal UI
|
||||
```bash
|
||||
AUTH_ZITADEL_ID=your-client-id
|
||||
AUTH_ZITADEL_SECRET=your-client-secret
|
||||
AUTH_ZITADEL_ISSUER=https://your-zitadel-instance
|
||||
```
|
||||
|
||||
### Client Side (`.env`)
|
||||
|
||||
```bash
|
||||
EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
|
||||
EXPO_PUBLIC_ZITADEL_ISSUER=https://your-zitadel-instance
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Card to Priority Test Set
|
||||
### Adding a New Convex Function
|
||||
|
||||
1. Add image to `TestImages/reference_alpha/` or appropriate folder
|
||||
2. Add entry to `GenerateCommand.PriorityCardsWithSets` dictionary
|
||||
3. Run `just gen-db` to regenerate database
|
||||
1. Add function to appropriate file in `convex/` (e.g., `cards.ts`)
|
||||
2. Run `bunx convex dev` to regenerate types
|
||||
3. Import from `convex/_generated/api` in client code
|
||||
|
||||
### Debugging Recognition Failures
|
||||
### Testing Recognition
|
||||
|
||||
1. Enable debug output in `MauiProgram.cs`:
|
||||
```csharp
|
||||
options.DebugOutputDirectory = "/sdcard/Download/scry-debug";
|
||||
```
|
||||
2. Run recognition
|
||||
3. Pull debug images: `adb pull /sdcard/Download/scry-debug`
|
||||
4. Compare `05_clahe_*.png` with reference images in database
|
||||
1. Add test images to `TestImages/`
|
||||
2. Use the scan tab in the app
|
||||
3. Check console logs for `[Scry]` prefixed messages
|
||||
|
||||
### Modifying Hash Algorithm
|
||||
### Debugging Camera Issues
|
||||
|
||||
1. Update `PerceptualHash.ComputeColorHash()`
|
||||
2. Update `CardRecognitionService.ColorHashBits` constant
|
||||
3. Regenerate database: `just gen-db --force`
|
||||
4. Run tests: `just test`
|
||||
- In Expo Go: Uses `expo-camera`, check for "Dev mode" indicator
|
||||
- In production: Uses Vision Camera, requires EAS build
|
||||
|
||||
## Gotchas
|
||||
## Dependencies
|
||||
|
||||
1. **Hash size is 24 bytes (192 bits)** - 3 RGB channels × 8 bytes each
|
||||
2. **Confidence threshold is 85%** - Configurable in `CardRecognitionService.MinConfidence`
|
||||
3. **Card detection is optional** - Controlled by `RecognitionOptions.EnableCardDetection`
|
||||
4. **Rotation matching tries 4 orientations** - Controlled by `RecognitionOptions.EnableRotationMatching`
|
||||
5. **Database is bundled in APK** - Copied on first run to app data directory
|
||||
6. **Multi-face cards** - Only front face image is used for hashing
|
||||
7. **Rate limiting** - DbGenerator uses 50ms delay between Scryfall image downloads
|
||||
### Core
|
||||
|
||||
## CI/CD
|
||||
- `expo` ~54.0.33
|
||||
- `expo-router` ~6.0.23
|
||||
- `convex` ^1.31.7
|
||||
- `@convex-dev/auth` ^0.0.90
|
||||
- `react` 19.1.0
|
||||
- `react-native` 0.81.5
|
||||
|
||||
Forgejo Actions workflow (`.forgejo/workflows/release.yml`):
|
||||
- Builds for win-x64, linux-x64, osx-x64
|
||||
- Creates "standard" and "embedded" (with APK) variants
|
||||
- Publishes to Forgejo releases
|
||||
### Image Processing
|
||||
|
||||
- `@shopify/react-native-skia` ^2.4.18
|
||||
- `react-native-fast-opencv` ^0.4.7
|
||||
|
||||
### Camera
|
||||
|
||||
- `expo-camera` ^17.0.10 (Expo Go)
|
||||
- `react-native-vision-camera` ^4.7.3 (production)
|
||||
|
||||
### Auth
|
||||
|
||||
- `expo-auth-session` ^7.0.10
|
||||
- `expo-secure-store` ^15.0.8
|
||||
|
||||
## External Resources
|
||||
|
||||
- [Scryfall API](https://scryfall.com/docs/api) - Card data source
|
||||
- [CARD_RECOGNITION.md](docs/CARD_RECOGNITION.md) - Detailed architecture doc
|
||||
- [Convex Docs](https://docs.convex.dev/) - Backend documentation
|
||||
- [Expo Router](https://docs.expo.dev/router/introduction/) - Navigation
|
||||
- [docs/CARD_RECOGNITION.md](docs/CARD_RECOGNITION.md) - Recognition architecture
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue