# StoneLens Field ID — build spec

A rock and mineral identifier that prioritizes clear AI reasoning, a real free scan allowance, and in-app subscription control before adding any marketplace or valuation features.

## Project context

This spec describes an independent, alternative Android app you are building from scratch to compete with an existing incumbent app on Google Play — not a modification, clone, or reskin of the incumbent's own code, assets, or branding. Use the incumbent only as a market reference (via the report data below), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** RockIn Rock&Meteorite Identify
- **Package id:** `com.rockin.app`
- **Google Play:** https://play.google.com/store/apps/details?id=com.rockin.app
- **appy.fyi report:** https://appy.fyi/report/com.rockin.app
- **Category:** Lifestyle

## Overview

- **Working name:** StoneLens Field ID (trademark cleared: no)
- **Package id:** `fyi.appy.stonelensfieldid`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 6 weeks
- **Pricing:** subscription, $4.99 via `revenuecat`
- **Runtime AI:** yes (ai_gateway_llm): Identify one captured rock or mineral photo and return primary answer, confidence, reasoning, and three candidates. ≈ $0.02/call
- **Permissions:** `CAMERA`, `ACCESS_FINE_LOCATION`, `INTERNET`

## Non-goals (out of scope for v1)

- No marketplace, peer-to-peer trading, buying, selling, or specimen listings in v1.
- No live valuation, appraisal, investment guidance, or price-estimation layer in v1.
- No custom-trained computer-vision classifier or proprietary labeled mineral dataset in v1; identification uses a multimodal model API.
- No promise of perfect identification from a photo; visually ambiguous minerals must be labeled as ambiguous instead of forced into a single answer.
- No support-ticket-only cancellation flow; billing management must be reachable from inside the app.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.10.01
- **Gradle:** 8.9

| Purpose | Gradle coordinate |
| --- | --- |
| AndroidX core Kotlin extensions | `androidx.core:core-ktx:1.15.0` |
| Main Activity integration for Jetpack Compose | `androidx.activity:activity-compose:1.9.3` |
| Compose Material 3 components | `androidx.compose.material3:material3:1.3.0` |
| Compose UI runtime and primitives | `androidx.compose.ui:ui:1.7.5` |
| Compose UI tooling previews | `androidx.compose.ui:ui-tooling-preview:1.7.5` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware state collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Camera capture preview and image capture | `androidx.camera:camera-camera2:1.4.0` |
| Camera lifecycle binding | `androidx.camera:camera-lifecycle:1.4.0` |
| CameraX Compose-compatible preview view | `androidx.camera:camera-view:1.4.0` |
| Image loading for captured photos and reference images | `io.coil-kt:coil-compose:2.7.0` |
| Local offline persistence for scans, candidates, and reference minerals | `androidx.room:room-runtime:2.6.1` |
| Room coroutine APIs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Device GPS tagging for collection records | `com.google.android.gms:play-services-location:21.3.0` |
| JSON parsing for bundled mineral reference data and AI responses | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Anonymous Firebase identity for quota and entitlement records | `com.google.firebase:firebase-auth:23.1.0` |
| Firestore storage for server-side free-scan usage and subscription entitlement snapshots | `com.google.firebase:firebase-firestore:25.1.1` |
| Callable Firebase Function used as the protected AI gateway | `com.google.firebase:firebase-functions:21.1.0` |
| Subscription entitlement purchase flow and customer info | `com.revenuecat.purchases:purchases:8.11.0` |

## Design system

- **Primary color:** `#2F6F4E`
- **Background color:** `#F7F2EA`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `diamond` (regular weight)
- **Theme notes:** Use a field-notebook look: light theme background #F7F2EA with dark text #1F1B16; dark theme background #15130F with primary #8FCFA9. Use rounded 16dp cards for scan results, mineral property chips, and quota notices. Do not use gem-market luxury styling or price/appraisal visual language.

## Screens

### Onboarding
- **Route:** `onboarding`
- **Purpose:** Explain photo-based identification limits, the free scan allowance, and the $4.99/month subscription before the first scan.
- **Reached via:** app launch when onboardingCompleted is false
- **Key UI elements:** App title and diamond glyph; Accuracy disclaimer that visually similar minerals may be ambiguous; Free scan allowance statement; $4.99/month subscription statement; Continue button
- **States:** first_launch, returning_user_completed

### Home
- **Route:** `home`
- **Purpose:** Primary dashboard for starting a scan, viewing remaining free scans, and opening the collection log.
- **Reached via:** app launch after onboarding; back navigation from Camera Capture; back navigation from Identification Result; bottom navigation home item
- **Key UI elements:** Remaining free scans card; Start rock scan button; Latest scan summary card; Collection log button; Manage subscription button
- **States:** loading, empty_no_scans, free_scans_available, free_scans_exhausted_not_subscribed, subscribed, error

### Camera Capture
- **Route:** `camera`
- **Purpose:** Capture a specimen photo for AI identification.
- **Reached via:** tap Start rock scan on Home; tap Retake from Identification Result
- **Key UI elements:** Camera preview; Capture shutter button; Retake button after capture; Use photo button after capture; Camera permission explanation
- **States:** permission_not_requested, permission_denied, camera_loading, camera_ready, capture_in_progress, photo_captured, camera_error

### Identification Result
- **Route:** `result/{scanId}`
- **Purpose:** Show the AI identification, confidence, reasoning, ambiguity, and save status for one scan.
- **Reached via:** tap Use photo on Camera Capture; tap a scan row in Collection Log; tap latest scan summary on Home
- **Key UI elements:** Captured photo; Primary mineral or rock name; Confidence percentage; Reasoning bullets for color, luster, crystal habit, and visible cues; Top alternative candidates list; Ambiguity warning card; Save to collection button; View reference details button
- **States:** identifying, identified_high_confidence, identified_ambiguous, quota_exhausted, network_error, ai_error, saved

### Mineral Reference
- **Route:** `reference/{mineralSlug}`
- **Purpose:** Display reference properties and comparison images for the selected candidate mineral or rock.
- **Reached via:** tap View reference details on Identification Result; tap a candidate row on Identification Result; tap a mineral name in Specimen Detail
- **Key UI elements:** Mineral name; Reference comparison image carousel; Properties table for color, luster, hardness, streak, crystal habit, and common lookalikes; Back to result button
- **States:** loading, populated, missing_reference, error

### Collection Log
- **Route:** `collection`
- **Purpose:** Offline-capable list of saved rock and mineral scans with date, thumbnail, and optional GPS tag.
- **Reached via:** tap Collection log on Home; bottom navigation collection item
- **Key UI elements:** Saved scans list; Search field; Offline available badge; Empty collection message; Scan row with thumbnail, name, date, and location indicator
- **States:** loading, empty, populated, search_no_results, error

### Specimen Detail
- **Route:** `specimen/{scanId}`
- **Purpose:** Show one saved collection item, its photo, identification history, GPS coordinates, notes, and reference link.
- **Reached via:** tap a saved scan in Collection Log; tap Save to collection then View saved item from Identification Result
- **Key UI elements:** Captured photo; Saved mineral or rock name; Confidence and candidates; Latitude and longitude if captured; Editable notes field; Delete from collection button
- **States:** loading, populated, not_found, saving_notes, delete_confirm, error

### Subscription
- **Route:** `subscription`
- **Purpose:** Show subscription status, start the $4.99/month plan after free scans are used, and provide self-service cancellation access.
- **Reached via:** tap Manage subscription on Home; tap Subscribe after quota exhausted on Identification Result; tap Manage subscription from Settings
- **Key UI elements:** Current entitlement status; $4.99/month plan card; Subscribe button; Cancel subscription button when active; Restore purchases button; Billing transparency text
- **States:** loading_customer_info, free_scans_available, free_scans_exhausted_not_subscribed, purchase_in_progress, subscribed_active, cancelled_entitlement_until_period_end, purchase_error, restore_error

### Settings
- **Route:** `settings`
- **Purpose:** Provide privacy, billing, support contact visibility, and app information without making cancellation depend on support.
- **Reached via:** bottom navigation settings item; overflow menu from Home
- **Key UI elements:** Manage subscription row; Privacy policy row; Support email row; AI accuracy disclaimer row; App version row
- **States:** populated

## Data model

### ScanRecord (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID generated before identification starts |
| photoUri | `String` | content URI string for app-private captured JPEG |
| createdAt | `Instant` | stored with Room type converter as epoch milliseconds |
| primaryName | `String` | empty until identification succeeds |
| primarySlug | `String` | empty until identification succeeds |
| confidence | `Double` | 0.0 to 1.0 |
| reasoning | `String` | newline-separated explanation bullets returned by AI gateway |
| isAmbiguous | `Boolean` | true when confidence < 0.70 or top-two confidence gap < 0.15 |
| latitude | `Double?` | nullable; set only if location permission is granted and a recent fix is available |
| longitude | `Double?` | nullable; set only if location permission is granted and a recent fix is available |
| notes | `String` | user-editable collection notes |
| savedToCollection | `Boolean` | false for transient result until user taps Save to collection |

### IdentificationCandidate (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| scanId | `String` | foreign key to ScanRecord.id, cascade delete |
| rank | `Int` | 1 for top candidate, then 2 and 3 |
| name | `String` |  |
| slug | `String` | lowercase hyphenated reference key |
| confidence | `Double` | 0.0 to 1.0 |
| whyPossible | `String` | one-sentence explanation from AI gateway |
| whyNotCertain | `String` | one-sentence uncertainty note from AI gateway |

### MineralReference (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| slug | `String` | primary key; seeded from bundled assets/reference/minerals_v1.json |
| name | `String` |  |
| category | `String` | mineral, gemstone, or rock |
| colors | `String` | comma-separated display text |
| luster | `String` |  |
| hardnessMohs | `String` | text range such as 6.5-7 |
| streak | `String` |  |
| crystalHabit | `String` |  |
| commonLookalikes | `String` | comma-separated display text |
| imageAssetPaths | `String` | JSON array string of bundled asset paths |

### LocalUsageCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| userId | `String` | primary key; Firebase anonymous uid |
| freeScanLimit | `Int` | v1 fixed limit is 10 |
| freeScansUsed | `Int` | last value synced from Firestore |
| isSubscribed | `Boolean` | last value from RevenueCat customer info or Firestore entitlement snapshot |
| updatedAt | `Instant` | stored as epoch milliseconds |

### UserQuota (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | document id, Firebase anonymous uid |
| freeScanLimit | `Long` | set to 10 when document is first created |
| freeScansUsed | `Long` | incremented transactionally by identifySpecimen function only after an AI call succeeds |
| createdAt | `Instant` | server timestamp |
| updatedAt | `Instant` | server timestamp |

### UserEntitlement (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | document id, Firebase anonymous uid |
| isSubscribed | `Boolean` | true when RevenueCat entitlement for monthly plan is active |
| productId | `String` | monthly subscription product id configured in Play Console and RevenueCat |
| expiresAt | `Instant?` | nullable; current paid period end when available |
| cancelledAt | `Instant?` | nullable; set when RevenueCat reports cancellation |
| updatedAt | `Instant` | server timestamp |

## Features

### Camera-based specimen scan

Let the user capture a rock or mineral photo and submit it for identification.

- **Answers complaint:** baseline parity

- **Screens:** Home, Camera Capture, Identification Result

- **Estimated hours:** 24

**Implementation notes:** Use CameraX PreviewView inside the Camera Capture screen. Bind Preview and ImageCapture to the lifecycle with CameraSelector.DEFAULT_BACK_CAMERA. Save captures as JPEG files under context.filesDir/scans/{scanId}.jpg using ImageCapture.OutputFileOptions. After capture, show the still image with Coil and only call identification when the user taps Use photo. If CAMERA permission is denied, render the permission_denied state with a button that opens ACTION_APPLICATION_DETAILS_SETTINGS.

**Acceptance criteria:**
- Tapping Start rock scan from Home opens Camera Capture.
- When camera permission is granted, a live preview appears and the shutter button is enabled.
- After tapping the shutter, a JPEG file exists in app-private storage and the screen shows Retake and Use photo buttons.
- Tapping Retake discards the pending file and returns to a live preview.
- Tapping Use photo creates a ScanRecord with savedToCollection=false and navigates to result/{scanId}.

### AI identification with reasoning

Send the captured photo to a protected multimodal AI gateway and display a primary identification with confidence and visible-cue reasoning.

- **Answers complaint:** Identification accuracy is the largest and most repeated: reviewers describe the AI returning the same result for different rocks, mistaking common stones (quartz, amethyst, labradorite) for rarer ones, or asking the user to guess among several similar-looking options instead of giving an answer.

- **Screens:** Identification Result, Mineral Reference, Collection Log, Specimen Detail

- **Estimated hours:** 52

**Implementation notes:** Implement a Firebase Callable Function named identifySpecimen that accepts scanId and a base64 JPEG compressed to max 1600px on the longest edge at JPEG quality 85. The Android app must not contain the AI API key. The function sends the image to the multimodal model with a prompt requiring: identify likely mineral or rock; explicitly check color, luster, crystal habit, translucency, banding, cleavage/fracture, and visible matrix; do not choose a rare mineral unless the visible evidence supports it; return JSON with primaryName, primarySlug, confidence 0..1, reasoningBullets array, and exactly three candidates. Parse the response with kotlinx.serialization, persist ScanRecord and IdentificationCandidate rows in Room, and show the result. If the callable returns malformed JSON, show ai_error and do not increment local freeScansUsed.

**Acceptance criteria:**
- The app sends identification requests only through the Firebase callable and contains no AI provider secret in Android resources, BuildConfig, or source files.
- A successful response persists one ScanRecord and exactly three IdentificationCandidate rows.
- The result screen shows the captured photo, primary name, confidence percentage, and at least four reasoning bullets.
- The prompt sent by the function contains the terms color, luster, crystal habit, hardness cues, and common lookalikes.
- If the function returns malformed JSON, the result screen shows ai_error and no candidate rows are inserted.

### Transparent ambiguity handling

Flag genuinely uncertain identifications instead of forcing the user to guess among lookalikes.

- **Answers complaint:** Identification accuracy is the largest and most repeated: reviewers describe the AI returning the same result for different rocks, mistaking common stones (quartz, amethyst, labradorite) for rarer ones, or asking the user to guess among several similar-looking options instead of giving an answer.

- **Screens:** Identification Result, Collection Log, Specimen Detail

- **Estimated hours:** 18

**Implementation notes:** After parsing candidates, compute isAmbiguous=true when primary confidence is below 0.70 or when candidate[0].confidence - candidate[1].confidence is below 0.15. In identified_ambiguous state, title the card 'Likely, but not certain' and show all three candidates with whyPossible and whyNotCertain text. Do not hide the primary answer; keep it as the top candidate while making uncertainty visible. Store isAmbiguous on ScanRecord so the collection log can show an ambiguity badge offline.

**Acceptance criteria:**
- A response with primary confidence 0.69 sets ScanRecord.isAmbiguous=true.
- A response with top confidence 0.82 and second confidence 0.70 sets ScanRecord.isAmbiguous=true.
- A response with top confidence 0.82 and second confidence 0.60 sets ScanRecord.isAmbiguous=false.
- Ambiguous results show the text 'Likely, but not certain' and all three candidate names.
- Saved ambiguous scans show an ambiguity badge in Collection Log without needing network access.

### Bundled mineral reference data

Provide offline reference properties and comparison images for candidate minerals and rocks.

- **Answers complaint:** baseline parity

- **Screens:** Mineral Reference, Identification Result, Specimen Detail

- **Estimated hours:** 32

**Implementation notes:** Package a JSON seed file at app/src/main/assets/reference/minerals_v1.json and image assets under app/src/main/assets/reference/images/. On first launch, compute a SHA-256 of minerals_v1.json; if it differs from the stored seed hash, replace all MineralReference rows in one Room transaction. The v1 seed must include entries for quartz, amethyst, and labradorite because those are named in the complaint, and each entry must include colors, luster, hardnessMohs, streak, crystalHabit, commonLookalikes, and at least two imageAssetPaths. Mineral Reference reads only from Room and loads bundled assets with Coil using file:///android_asset/ paths.

**Acceptance criteria:**
- After first launch, Room contains MineralReference rows for slugs quartz, amethyst, and labradorite.
- Opening reference/quartz works with airplane mode enabled.
- Each seeded reference row has non-empty colors, luster, hardnessMohs, streak, crystalHabit, and commonLookalikes fields.
- Mineral Reference shows at least two comparison images when the selected reference row has two imageAssetPaths.
- If a candidate slug has no reference row, Mineral Reference shows missing_reference rather than crashing.

### Collection log with GPS tagging and offline mode

Let users save identified specimens locally with optional GPS coordinates and notes, available without network access.

- **Answers complaint:** baseline parity

- **Screens:** Identification Result, Collection Log, Specimen Detail

- **Estimated hours:** 36

**Implementation notes:** When the result screen is created, request a single recent location only if ACCESS_FINE_LOCATION has been granted. Use FusedLocationProviderClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, cancellationToken) with a 5-second timeout; if it fails, leave latitude and longitude null and continue. Tapping Save to collection sets savedToCollection=true. Collection Log and Specimen Detail read exclusively from Room, so saved scans, candidates, notes, and reference rows remain available offline. Notes are updated with a debounced 500ms Room update from the Specimen Detail text field.

**Acceptance criteria:**
- Tapping Save to collection changes savedToCollection to true for that ScanRecord.
- If location permission is denied, saving still succeeds and latitude/longitude remain null.
- If a location fix is returned, latitude and longitude are persisted on ScanRecord.
- With network disabled, Collection Log still lists saved scans from Room.
- Editing notes in Specimen Detail persists the new text and it remains after app restart.

### Honest free scan tier

Give each user 10 real full-detail identifications before showing any payment requirement.

- **Answers complaint:** A third, smaller cluster is the paywall itself — core identification gated behind a roughly $10/week subscription with only a handful of free scans, before a user can judge whether the tool works at all.

- **Screens:** Home, Identification Result, Subscription

- **Estimated hours:** 28

**Implementation notes:** On first app start, sign in with Firebase anonymous auth and create UserQuota(uid) in Firestore with freeScanLimit=10 and freeScansUsed=0 if missing. Home and Subscription display remaining scans as freeScanLimit - freeScansUsed. The identifySpecimen callable must check UserEntitlement.isSubscribed or remaining free scans before calling the AI model. It increments freeScansUsed in a Firestore transaction only after a successful AI response is produced. The app must never display a paywall before the 10th successful free identification; failed camera captures, network errors, malformed AI responses, and user retakes do not consume a scan.

**Acceptance criteria:**
- A new anonymous user sees 10 remaining free scans on Home.
- After one successful identification, Home shows 9 remaining free scans.
- A network_error or ai_error does not decrement remaining free scans.
- The Subscription screen can be opened manually before quota is exhausted, but no payment prompt blocks Camera Capture while remaining free scans are greater than 0.
- When freeScansUsed equals 10 and the user is not subscribed, attempting Use photo navigates to Identification Result in quota_exhausted state with a Subscribe button.

### Subscription purchase and self-service cancellation

Offer the report's $4.99/month subscription and make billing management reachable inside the app without contacting support.

- **Answers complaint:** Billing is the second cluster: several reviewers report being charged immediately on what was advertised as a free trial, continuing to be billed after cancelling, and being unable to find a cancel button or working support contact.

- **Screens:** Home, Subscription, Settings, Identification Result

- **Estimated hours:** 30

**Implementation notes:** Use RevenueCat for the monthly entitlement. Configure one monthly product in Play Console and RevenueCat at $4.99/month; do not configure a free trial or introductory trial in v1, so the app never advertises a trial or risks charging before a stated trial end. Subscription screen calls Purchases.getOfferings(), shows one plan card labeled '$4.99/month', and calls purchasePackage on Subscribe. For active subscribers, show a Cancel subscription button that opens the Play subscription management deep link: https://play.google.com/store/account/subscriptions?sku={productId}&package={applicationId}. Also show Restore purchases, which calls Purchases.restorePurchases() and refreshes CustomerInfo. Mirror active entitlement to Firestore UserEntitlement through a backend webhook or callable refresh so the AI gateway can allow subscribed scans.

**Acceptance criteria:**
- The Subscription screen shows exactly one recurring plan labeled $4.99/month.
- No screen contains the phrase free trial in v1.
- A subscribed user sees a visible Cancel subscription button without opening support or email.
- Tapping Cancel subscription launches an ACTION_VIEW intent whose URL starts with https://play.google.com/store/account/subscriptions.
- After RevenueCat CustomerInfo reports no active entitlement, Home no longer shows subscribed state and the AI gateway requires remaining free quota.

### Billing transparency onboarding and settings

Make price, free scan count, cancellation path, and support information visible before purchase.

- **Answers complaint:** Billing is the second cluster: several reviewers report being charged immediately on what was advertised as a free trial, continuing to be billed after cancelling, and being unable to find a cancel button or working support contact.

- **Screens:** Onboarding, Settings, Subscription

- **Estimated hours:** 12

**Implementation notes:** Onboarding must include three fixed statements: '10 full-detail scans are free', 'The optional plan is $4.99/month', and 'You can manage or cancel from Settings > Manage subscription'. Store onboardingCompleted in DataStore is not required; use Room LocalUsageCache existence plus a SharedPreferences boolean named onboarding_completed to avoid adding another dependency. Settings must include Manage subscription above Support email so cancellation is not presented as a support request. The support email row opens ACTION_SENDTO mailto:support@stonelens.example.

**Acceptance criteria:**
- First launch routes to Onboarding before Home.
- Onboarding visibly contains '10 full-detail scans are free'.
- Onboarding visibly contains '$4.99/month'.
- Settings shows Manage subscription above Support email.
- Tapping Support email opens a mailto intent and does not affect subscription state.

### Polish, error handling, and beta readiness

Provide complete loading, empty, offline, and error states across the v1 navigation graph.

- **Answers complaint:** baseline parity

- **Screens:** Onboarding, Home, Camera Capture, Identification Result, Mineral Reference, Collection Log, Specimen Detail, Subscription, Settings

- **Estimated hours:** 8

**Implementation notes:** Implement a single Compose NavHost with routes exactly matching the screens array. Every repository call returns a sealed UI state: Loading, Empty where applicable, Populated, or Error(message, retryAction). Network calls to identifySpecimen use a 45-second timeout and expose retry only when quota was not consumed. Add version text to Settings using BuildConfig.VERSION_NAME. Use consistent cards, property chips, and error banners from the design system colors.

**Acceptance criteria:**
- Every route listed in screens is registered in the NavHost.
- Home shows loading before quota and latest scan data are available.
- Collection Log shows empty when no saved ScanRecord has savedToCollection=true.
- Identification Result shows network_error when the callable times out after 45 seconds.
- Settings displays the app version from BuildConfig.VERSION_NAME.

## Store listing

- **Title:** StoneLens Rock ID
- **Short description:** Identify rocks with clear AI reasoning and honest billing.
- **Category:** Education
- **Keywords:** rock identifier, mineral identifier, rockhounding, gemstone identification, quartz, amethyst, labradorite, geology, stone scanner
- **Icon prompt:** Create a modern Android app icon for a rock and mineral identification app. Use a single faceted green mineral crystal or diamond-shaped stone centered on a warm off-white field-notebook background. Style should be clean vector, Material-inspired, no text, no dollar signs, no marketplace symbols, no camera lens. Primary colors: forest green #2F6F4E, pale stone #F7F2EA, subtle dark outline #1F1B16. Rounded adaptive icon composition with generous padding.

**Long description:**

StoneLens Field ID helps rockhounds photograph a specimen, get a likely rock or mineral identification, and understand the visible clues behind the answer. Each result shows confidence, reasoning, and lookalike candidates so ambiguous stones are not forced into a fake-certain answer.

Start with 10 full-detail free scans before any payment is required. If you want ongoing scans, the optional plan is $4.99/month, and subscription management is available from inside the app.

Save finds to an offline collection log with notes and optional GPS coordinates. Reference pages include mineral properties such as color, luster, hardness, streak, crystal habit, and common lookalikes.

StoneLens is for educational field identification, not appraisal, trading, or guaranteed laboratory confirmation.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://stonelens.example/privacy (privacy claims verified: no)
- **Data collected:** Specimen photos submitted for AI identification; Optional precise location coordinates for saved specimens when the user grants location permission; Anonymous Firebase user ID; Free scan usage count; Subscription entitlement and purchase status; User-entered specimen notes

## Test plan

### 1. AI returning the same result for different rocks (unit)

1. Create FakeIdentifyGateway with three fixture image names: quartz.jpg, amethyst.jpg, labradorite.jpg.
2. Configure fake responses with primarySlug values quartz, amethyst, and labradorite respectively.
3. Call IdentifyRepository.identify for each fixture using unique scanIds.
4. Read ScanRecord rows from the in-memory Room database.
5. Collect primarySlug values in insertion order.

**Expected:** The three persisted primarySlug values are exactly quartz, amethyst, and labradorite; no previous result is reused for a later scan.

### 2. Asking the user to guess among several similar-looking options instead of giving an answer (unit)

1. Build an IdentificationResponse with candidates quartz confidence 0.72, amethyst confidence 0.64, and fluorite confidence 0.40.
2. Pass the response to the ambiguity mapper.
3. Read the mapped ScanRecord.isAmbiguous and candidate list.

**Expected:** isAmbiguous is true, the top candidate remains quartz, and all three candidates are retained for display with uncertainty text.

### 3. Core identification gated behind a roughly $10/week subscription with only a handful of free scans (unit)

1. Create UserQuota with freeScanLimit=10, freeScansUsed=0, and isSubscribed=false.
2. Call the quota gate for scan attempts 1 through 10 and mark each as successful.
3. After each successful call, read remaining scans.
4. Call the quota gate for scan attempt 11.

**Expected:** Attempts 1 through 10 are allowed without requiring purchase, remaining scans count down to 0, and attempt 11 returns quota_exhausted.

### 4. Never charge before a trial's stated end date (unit)

1. Load the v1 subscription product configuration used by the SubscriptionViewModel test fixture.
2. Inspect plan display strings exposed to the Subscription screen.
3. Inspect onboarding billing strings.

**Expected:** No displayed string contains 'trial' or 'free trial'; the only price string is '$4.99/month'.

### 5. Unable to find a cancel button or working support contact (instrumented)

1. Launch the app with a fake RevenueCat CustomerInfo containing an active monthly entitlement.
2. Navigate to Settings.
3. Tap Manage subscription.
4. Wait for Subscription screen.
5. Find the Cancel subscription button and tap it while intercepting outgoing intents.

**Expected:** A visible Cancel subscription button exists, and tapping it emits an ACTION_VIEW intent with a Play subscriptions URL containing both sku= and package= query parameters.

### 6. Free scans before any payment prompt (instrumented)

1. Install fresh app data.
2. Complete Onboarding.
3. Mock Firestore quota as freeScanLimit=10 and freeScansUsed=0.
4. Open Home.
5. Tap Start rock scan.

**Expected:** Home shows 10 remaining free scans, Camera Capture opens directly, and no subscription modal or purchase sheet appears.

### 7. Baseline parity: saved collection works offline (instrumented)

1. Insert a saved ScanRecord with savedToCollection=true and one IdentificationCandidate into the Room test database.
2. Disable network for the test process or use repositories configured to throw on network access.
3. Launch Collection Log.
4. Tap the inserted scan row.

**Expected:** Collection Log displays the saved scan, Specimen Detail opens, and no network error is shown.

### 8. Mistaking common stones quartz, amethyst, and labradorite for rarer ones (manual)

1. Prepare three clear photos taken in normal daylight: one common quartz, one amethyst, and one labradorite.
2. Install a release build connected to the production AI gateway.
3. Run one scan for each specimen using the same account with free scans available.
4. Record the primaryName, confidence, and top three candidates for each result.

**Expected:** The three scans do not all return the same primaryName; quartz, amethyst, and labradorite each appear either as the primary answer or among the top three candidates for their corresponding specimen, with visible reasoning bullets.

### 9. Continuing to be billed after cancelling (manual)

1. Purchase the monthly subscription using a Play Billing test account.
2. Open Settings > Manage subscription.
3. Tap Cancel subscription and complete cancellation in the Play subscription management page.
4. Return to the app and tap Restore purchases.
5. Wait for CustomerInfo refresh.

**Expected:** The app shows cancelled_entitlement_until_period_end or not subscribed according to the Play test subscription state, and it does not instruct the user to contact support to cancel.

## Build instructions

```sh
keytool -genkeypair -v -keystore release-keystore.jks -storepass changeit123 -keypass changeit123 -alias stonelens_upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=StoneLens Upload,O=StoneLens,C=US"
cat > signing.properties <<'EOF'
storeFile=release-keystore.jks
storePassword=changeit123
keyAlias=stonelens_upload
keyPassword=changeit123
EOF
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
