An AI-powered Android app that detects counterfeit products using dual-AI verification — Google Gemini Vision + Claude Haiku — delivering high-confidence authenticity assessments in seconds.
Portfolio Project by Lakshmana Reddy | Android Tech Lead | GitHub
| Scan Result (Barcode) | Scan Result (No Barcode) |
|---|---|
![]() |
![]() |
| Tylenol · Barcode detected · 95/100 ✅ | Children's Acetaminophen · Image-only · 95/100 ✅ |
- CameraX live preview — real-time camera feed with ML Kit barcode overlay
- ML Kit barcode scanning — on-device, instant, free, works offline
- Gemini 2.5 Flash Vision — analyzes product packaging visually (text, logos, fonts, print quality)
- Claude Haiku 4.5 verification — cross-validates Gemini's reasoning for a refined verdict
- Authenticity score 0–100 — clear verdict:
AUTHENTIC,SUSPICIOUS, orLIKELY_FAKE - Red flag detection — specific concerns flagged (mismatched labels, blurry print, wrong fonts, etc.)
- TTS voice output — result is read aloud after every scan
- Room scan history — all scans persisted locally with timestamps
- Swipe-to-delete — remove individual history entries with a swipe gesture
- Smart scan validation — warns before scanning plain objects without packaging or barcode to prevent misleading results
- Offline detection — friendly message shown when no internet is available
- Rate limit handling — countdown banners for Gemini 429 / daily quota errors
- Clean Architecture + MVVM + Hilt + UDF
flowchart LR
CAM([Camera]) --> ML[ML Kit\nBarcode]
ML --> VAL{Scan\nValidation}
VAL -->|plain object\nno barcode\ncategory=OTHER| WARN[Warning Dialog]
WARN -->|Scan Anyway| GEM
VAL -->|packaged product\nor category set| GEM[Gemini 2.5 Flash\nVision Analysis]
GEM --> CLU[Claude Haiku\nCross-Verification]
CLU --> RES([Result])
RES --> TTS[TTS\nVoice Output]
style CAM fill:#e65100,color:#fff
style ML fill:#f57f17,color:#fff
style VAL fill:#6a1b9a,color:#fff
style WARN fill:#ad1457,color:#fff
style GEM fill:#1565c0,color:#fff
style CLU fill:#2e7d32,color:#fff
style RES fill:#e65100,color:#fff
style TTS fill:#4a148c,color:#fff
- ML Kit reads any barcode in the live camera frame in real-time before you tap Capture.
- Scan Validation checks before proceeding — if no barcode is detected and category is OTHER, a warning dialog is shown to prevent meaningless scans.
- Gemini 2.5 Flash receives the captured JPEG and analyzes packaging visually — logos, text, fonts, print quality, barcode data.
- Claude Haiku receives Gemini's text analysis (no image) and cross-checks the reasoning for a refined final verdict.
- TTS reads the result aloud. If Claude fails, Gemini's result is used directly — the app never crashes.
graph TB
subgraph UI["UI Layer — Jetpack Compose"]
SC[ScanScreen] --> SVM[ScanViewModel]
RS[ResultScreen]
HS[HistoryScreen] --> HVM[HistoryViewModel]
end
subgraph DOMAIN["Domain Layer — Pure Kotlin"]
SUC[ScanProductUseCase]
GHC[GetScanHistoryUseCase]
PR[ProductRepository interface]
end
subgraph DATA["Data Layer"]
PRI[ProductRepositoryImpl]
GVI[GeminiVisionApiImpl]
CAI[ClaudeVerificationApiImpl]
DB[Room Database]
end
SVM --> SUC
HVM --> GHC
SUC --> PR
GHC --> PR
PR -.->|implements| PRI
PRI --> GVI
PRI --> CAI
PRI --> DB
style UI fill:#1a237e,color:#fff
style DOMAIN fill:#1b5e20,color:#fff
style DATA fill:#b71c1c,color:#fff
graph LR
TAP([User Taps Capture]) --> VM[ViewModel]
VM --> UC[ScanProductUseCase]
UC --> REPO[ProductRepository]
REPO -->|ScanEvent| SF[StateFlow]
SF --> UI[ScanScreen recomposes]
style TAP fill:#e65100,color:#fff
style UC fill:#2e7d32,color:#fff
style UI fill:#1565c0,color:#fff
sequenceDiagram
actor User
participant SC as ScanScreen
participant VM as ScanViewModel
participant REPO as Repository
participant GEM as Gemini Vision
participant CLU as Claude Haiku
participant DB as Room DB
User->>SC: Tap Capture
SC->>VM: scanProduct(uri, barcode, category)
VM->>REPO: scanProduct(...)
REPO->>REPO: Check network
alt No internet
REPO-->>VM: ScanEvent.Result (offline message)
else Online
REPO->>GEM: POST image + prompt
GEM-->>REPO: score + verdict + redFlags
REPO->>CLU: POST Gemini analysis
CLU-->>REPO: refined verdict
REPO->>DB: save ScanEntity
REPO-->>VM: ScanEvent.Result
end
VM-->>SC: navigate to ResultScreen
Note over GEM,CLU: If Claude fails → Gemini result used directly
FakeProductDetector/
├── domain/ ← Pure Kotlin, zero Android dependencies
│ ├── model/ # Product, ScanResult, Verdict, Category
│ ├── repository/
│ │ └── ProductRepository.kt # Interface
│ └── usecase/
│ ├── ScanProductUseCase.kt
│ └── GetScanHistoryUseCase.kt
│
├── data/
│ ├── api/
│ │ ├── GeminiVisionApiImpl.kt # Image → base64 → Gemini
│ │ ├── ClaudeVerificationApiImpl.kt
│ │ └── GeminiQuotaError.kt # Sealed: TokenRPM | RequestRPM | Daily
│ ├── local/ # Room: ScanEntity, ScanDao, ScanDatabase
│ └── repository/
│ └── ProductRepositoryImpl.kt # Offline guard → Gemini → Claude → Room
│
├── di/
│ └── AppModule.kt # Hilt: 2× Retrofit, OkHttp, Room
│
└── ui/
├── scan/ # ScanScreen, ScanViewModel, ScanUiState
├── result/ # ResultScreen, ResultViewModel
├── history/ # HistoryScreen, HistoryViewModel
└── tts/ # TTSManager
| ML Kit | Gemini 2.5 Flash | Claude Haiku | |
|---|---|---|---|
| Role | Barcode Reader | Vision Scanner | Verifier |
| When runs | Every live frame | Once on capture | After Gemini |
| Input | Live camera frame | JPEG photo | Gemini's text output |
| Output | Barcode string | Score + verdict + flags | Refined verdict |
| Needs internet | No — on-device | Yes | Yes |
| Costs money | Free | ~$0.0001/scan | ~$0.0001/scan |
| Checks authenticity | No | Yes — detailed | Yes — reasoned |
| Gemini 2.5 Flash | Claude Haiku | |
|---|---|---|
| Role | The Scanner | The Verifier |
| Can see images? | Yes | No |
| Analogy | Lab technician running tests | Senior doctor reviewing results |
flowchart LR
IMG([Camera Image]) --> GEM
subgraph GEM["Gemini — SCANNER"]
G1[Reads image visually\nlogos · text · fonts · barcode]
G1 --> G2[Returns score + verdict + redFlags]
end
subgraph CLU["Claude — VERIFIER"]
C1[Receives text analysis\nno image]
C1 --> C2[Cross-checks reasoning\nRefines verdict]
end
GEM --> CLU
CLU --> OUT([Final Result])
style GEM fill:#1565c0,color:#fff
style CLU fill:#2e7d32,color:#fff
style IMG fill:#e65100,color:#fff
style OUT fill:#e65100,color:#fff
flowchart TD
CAM([Live Camera Feed])
subgraph REALTIME["Real-Time — Every Frame"]
ML[ML Kit Barcode Scanner\nOn-device · No internet · Free]
ML --> BD{Barcode found?}
BD -->|Yes| GB[Green badge — barcode shown]
BD -->|No| GR[Grey badge — no barcode]
end
CAM --> ML
TAP([User Taps Capture]) --> PHOTO
subgraph ONCAPTURE["On Capture — Once per scan"]
PHOTO[JPEG photo saved]
PHOTO --> GEM2[Gemini Vision]
GEM2 --> CLU2[Claude Haiku]
CLU2 --> RES[Result shown + TTS]
end
GB -.->|barcode passed along| PHOTO
GR -.->|null barcode| PHOTO
style REALTIME fill:#1565c0,color:#fff
style ONCAPTURE fill:#2e7d32,color:#fff
style CAM fill:#e65100,color:#fff
style TAP fill:#e65100,color:#fff
| Layer | Technology |
|---|---|
| Language | Kotlin 2.2.10 |
| UI | Jetpack Compose + Material 3 |
| Architecture | Clean Architecture + MVVM + UDF |
| DI | Hilt 2.59.1 |
| Camera | CameraX 1.3.4 |
| Barcode | ML Kit Barcode Scanning 17.3.0 |
| AI — Vision | Google Gemini 2.5 Flash (v1beta) |
| AI — Verification | Anthropic Claude Haiku 4.5 |
| TTS | Android TextToSpeech |
| Networking | Retrofit 2.11.0 + OkHttp 4.12.0 |
| Database | Room 2.7.1 |
| Image Loading | Coil 2.6.0 |
| Navigation | Navigation Compose 2.7.7 |
| Build | AGP 9.1.0, Kotlin 2.2.10 |
- Android Studio Hedgehog or newer
- Android device / emulator with camera (API 26+)
- Google Gemini API key (billing enabled)
- Anthropic API key
git clone https://github.com/lakshmanreddymv-bot/FakeProductDetector.git
cd FakeProductDetectorCreate local.properties in the project root — do not commit this file:
sdk.dir=/path/to/your/Android/sdk
gemini.api.key=YOUR_GEMINI_API_KEY_HERE
anthropic.api.key=YOUR_ANTHROPIC_API_KEY_HERE- Visit Google AI Studio → Get API Key
- Enable billing at Google Cloud Console
- The app uses
gemini-2.5-flashwhich requires a billing-enabled project
Cost per scan: Gemini + Claude together cost roughly $0.0002 per scan (~$0.0001 each).
./gradlew assembleDebugOr open in Android Studio → Run ▶
app/src/test/
└── com/example/fakeproductdetector/
├── data/api/
│ ├── GeminiQuotaErrorTest.kt ← Sealed class coverage
│ └── GeminiVisionApiImplTest.kt ← JSON parsing, verdict/score parsing
├── domain/
│ ├── model/ScanResultTest.kt ← Model classes, enums
│ └── usecase/ScanProductUseCaseTest.kt ← ScanEvent flow, Mockito
└── ui/scan/ScanUiStateTest.kt ← All sealed UI states
./gradlew test # All unit tests
./gradlew testDebugUnitTest # Debug variant only
./gradlew testDebugUnitTest --info # Verbose output| Area | Tests | Status |
|---|---|---|
| Domain models (Product, ScanResult, Verdict, Category) | 10 | 100% |
| Sealed UI states (Idle / Loading / Error / RateLimited / Success) | 10 | 100% |
| GeminiQuotaError sealed class | 6 | 100% |
| JSON extraction + verdict/score parsing | 13 | 100% |
| ScanProductUseCase — mocked repository, ScanEvent flow | 7 | 100% |
Real bugs encountered during development — useful for anyone building Android AI apps.
Problem: Camera preview showed a black screen after granting permission.
Cause: App jumped straight into camera without verifying runtime CAMERA permission was granted.
Fix: Added rememberLauncherForActivityResult(RequestPermission) + hasCameraPermission state gate in ScanScreen.
var hasCameraPermission by remember {
mutableStateOf(context.checkSelfPermission(CAMERA) == PERMISSION_GRANTED)
}Attempts:
v1beta/models/gemini-2.0-flash→ 404v1/models/gemini-2.0-flash→ 404
Cause: gemini-2.0-flash is no longer available to new users.
Fix: Switched to v1beta/models/gemini-2.5-flash.
@POST("v1beta/models/gemini-2.5-flash:generateContent")
suspend fun generateContent(@Body request: GeminiRequest): GeminiResponseProblem: On the free tier (15 RPM), failed scans triggered 3 retries → 3× 429 errors, extending the cooldown.
Fix: Removed all retry logic on 429. Throw immediately, show countdown banner. Added isScanning guard.
Problem: str_replace left duplicate parseResponse and extractJson functions in GeminiVisionApiImpl.
Fix: Full file overwrite to eliminate duplicates cleanly.
Problem: claude-haiku-4-5 → 404 from Claude API.
Fix: Correct model ID is claude-haiku-4-5-20251001.
Problem: Full-resolution camera images (5 MB+) caused 60s timeouts on Gemini API.
Fix: Added compressImage() — scales to max 1024 px, JPEG 85%, reducing 5 MB → ~150 KB.
Problem: Gemini Vision analysis takes 5–10s; OkHttp's 10s default caused sporadic failures. Fix:
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)Problem: After enabling Google Cloud billing, v1/models/gemini-2.0-flash still returned 404.
Cause: gemini-2.0-flash on v1 is restricted to pre-March 2026 customers.
Fix: Use v1beta/models/gemini-2.5-flash — available to all new billing users.
Point the camera at a product barcode. ML Kit detects it in real-time (green badge appears before tapping Capture).
Product: Tylenol Children's Oral Suspension
Barcode: 300450122377 (auto-detected)
Score: 95 / 100 AUTHENTIC
Gemini: UPC barcode resolves to Children's Tylenol Oral Suspension, Berry Flavor.
Print quality is clear and professional with no spelling errors.
Claude: Cross-verified and confirmed authentic.
Point at the side panel or back label. Gemini reads packaging text and analyzes it visually.
Product: Children's Liquid Acetaminophen
Barcode: No barcode detected — image-only scan
Score: 95 / 100 AUTHENTIC
Gemini analyzed:
Warnings section layout matches genuine Tylenol formatting
Acetaminophen dosage instructions are medically accurate
Overdose warning text matches official McNeil labeling
Font and spacing are professional quality
No internet connection detected.
Connect to the internet for full AI analysis.
The app saves the record locally and shows a friendly message — no crash, no spinner.
| With Barcode | Without Barcode | |
|---|---|---|
| Badge | Green | Grey |
| Product ID | Barcode + image | Image only |
| Works for | Packaged goods | Any label / packaging |
When you point the camera at a plain object with no barcode detected and category set to OTHER, the app warns you before scanning:
"For best results, point the camera at a product with visible packaging, labels, brand logos, or a barcode."
This prevents misleading results — for example, a sofa pillow scoring 95/100 Authentic.
| Scenario | Behaviour |
|---|---|
| Plain fabric, no barcode, category = OTHER | Warning dialog shown |
| Medicine box with barcode | Scan proceeds immediately |
| Electronics, no barcode, category = ELECTRONICS | Scan proceeds immediately |
| Any product with category set | Scan proceeds immediately |
Tap "Scan Anyway" to override and proceed. Tap "Cancel" to stay on the scan screen and reframe the shot.
- On-device ML model (TFLite) — pre-scan filter to skip cloud APIs for high-confidence cases
- Category auto-detection from image — remove need to select manually
- Product database for known counterfeits
- Share scan result as image / PDF
- Batch scanning mode
- Multi-language support
- Additional product categories
This is Project 2 in a series of AI-powered Android apps:
| # | Project | Status | Description |
|---|---|---|---|
| 1 | MySampleApplication-AI | Complete | AI assistant foundation |
| 2 | FakeProductDetector | Complete | Dual-AI product authentication |
| 3 | Coming Soon | Building | — |
MIT License — Copyright (c) 2026 Lakshmana Reddy
Lakshmana Reddy Android Tech Lead · 12 years experience · Pleasanton, CA GitHub

