# SafeParty Cars — build spec

A private-party car marketplace where verified buyers, scam-aware messaging, and strict explainable filters are the product rather than dealer inventory volume.

## 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:** Autotrader: Shop Cars For Sale
- **Package id:** `com.autotrader.android`
- **Google Play:** https://play.google.com/store/apps/details?id=com.autotrader.android
- **appy.fyi report:** https://appy.fyi/report/com.autotrader.android
- **Category:** Auto & Vehicles

## Overview

- **Working name:** SafeParty Cars (trademark cleared: no)
- **Package id:** `fyi.appy.safepartycars`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 12 weeks
- **Pricing:** one-time purchase, $20 via `play_billing_direct`
- **Runtime AI:** yes (ai_gateway_llm): Classify each outbound buyer/seller message for off-platform contact, fake vehicle-report language, cashier-check patterns, and repeated bot phrasing before writing it to Firestore. ≈ $0.0015/call
- **Permissions:** `INTERNET`, `POST_NOTIFICATIONS`

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

- No dealer inventory network or attempt to replicate Autotrader’s full marketplace breadth in v1.
- No financing, insurance, shipping, escrow, trade-in, or vehicle valuation tools in v1.
- No off-platform chat handoff; phone numbers and external links are treated as safety-sensitive content inside messages.
- No automated permanent bans based only on AI; suspicious content is quarantined or queued for review with human override controls.
- No VIN-decoder dependency or automated vehicle-history purchase flow in v1.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Android Kotlin extensions and platform utilities | `androidx.core:core-ktx:1.13.1` |
| Compose activity host and Activity Result APIs for notification permission and photo picker launchers | `androidx.activity:activity-compose:1.9.3` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Compose ViewModel integration | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Compose Navigation graph for all app screens | `androidx.navigation:navigation-compose:2.8.3` |
| Compose dependency version alignment | `androidx.compose:compose-bom:2024.10.01` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Core Compose UI runtime and layout primitives | `androidx.compose.ui:ui:1.7.4` |
| Compose tooling previews for debug builds | `androidx.compose.ui:ui-tooling-preview:1.7.4` |
| Async loading and caching of listing photos from Firebase Storage HTTPS URLs and local picker URIs | `io.coil-kt:coil-compose:2.7.0` |
| Firebase email/phone authentication for verified accounts | `com.google.firebase:firebase-auth:23.1.0` |
| Firestore marketplace, messaging, saved listing, report, and moderation data | `com.google.firebase:firebase-firestore:25.1.1` |
| Firebase Storage for listing photos | `com.google.firebase:firebase-storage:21.0.1` |
| Firebase Cloud Messaging for message and moderation notifications | `com.google.firebase:firebase-messaging:24.0.3` |
| Callable Firebase Functions for purchase validation and AI scam classification | `com.google.firebase:firebase-functions:21.1.0` |
| Kotlin coroutine await support for Firebase Tasks | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| Google Play Billing client for the $20 verified private-seller listing product | `com.android.billingclient:billing-ktx:7.1.1` |

## Design system

- **Primary color:** `#1F6F5B`
- **Background color:** `#F7F4EE`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `shield-check` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light background is #F7F4EE with white cards; dark theme uses #111411 background, #1C211E cards, and the same #1F6F5B primary adjusted only by Material tonal roles. Scam warnings use color_error_hex for high-risk banners and #8A5A00 amber text for review-needed warnings.

## Screens

### Sign In
- **Route:** `sign-in`
- **Purpose:** Authenticate users before browsing, saving, listing, or messaging.
- **Reached via:** app launch when FirebaseAuth has no current user; tap Sign in from Browse Search; tap Verify to contact seller from Listing Detail
- **Key UI elements:** App logo and safety positioning text; Email sign-in button; Phone verification button; Error banner; Continue-as-browsing prompt after successful auth
- **States:** loading, signed_out, code_sent, error, authenticated

### Verification
- **Route:** `verification`
- **Purpose:** Show whether the current user may contact sellers or publish verified private-party listings.
- **Reached via:** tap Verify account from Sign In; tap Contact seller while unverified on Listing Detail; tap Publish from Create Listing before verification is complete
- **Key UI elements:** Phone verified status row; Seller listing payment status row; Verification explanation card; Start phone verification button; Continue to listing payment button
- **States:** loading, unverified, phone_verified, seller_listing_paid, error

### Browse Search
- **Route:** `browse`
- **Purpose:** Browse private-party car listings with strict filters and visible explanations when filters remove results.
- **Reached via:** app launch after authentication check; bottom navigation Browse tab; back from Listing Detail
- **Key UI elements:** Search field; Filter chips for price range, mileage range, transmission, engine size, rental use, clean title, accident status, contact-for-price exclusion, no frame damage; Listing card list; Strict-filter explanation panel; Saved toggle on listing cards
- **States:** loading, empty, error, populated, filters_applied_no_results

### Listing Detail
- **Route:** `listing/{listingId}`
- **Purpose:** Display vehicle details, photos, seller verification, saved status, and the guarded contact action.
- **Reached via:** tap listing card on Browse Search; tap saved listing card on Saved Listings; tap listing link from Conversation
- **Key UI elements:** Swipeable photo gallery; Vehicle price, mileage, transmission, engine size, title status, accident status, frame damage, rental use; Seller verification badge; Save button; Contact seller button; Filter exception explanation if reached from search
- **States:** loading, not_found, error, populated, photo_error

### Saved Listings
- **Route:** `saved`
- **Purpose:** Let buyers revisit listings they saved while browsing.
- **Reached via:** bottom navigation Saved tab; tap Save on Listing Detail then open Saved tab
- **Key UI elements:** Saved listing list; Remove saved button; Empty state message; Open filters button
- **States:** loading, empty, error, populated

### Create Listing
- **Route:** `create-listing`
- **Purpose:** Let a private seller create or edit a verified listing with photos and vehicle details.
- **Reached via:** bottom navigation Sell tab; tap Edit on seller-owned Listing Detail
- **Key UI elements:** Photo picker grid with add/remove/reorder controls; Vehicle detail fields for year, make, model, trim, mileage, price, transmission, engine size, clean title, accident status, frame damage, rental use; Description field; Save draft button; Publish button
- **States:** new_draft, editing_existing, uploading_photos, validation_error, save_error, ready_to_publish

### Listing Payment
- **Route:** `listing-payment/{draftListingId}`
- **Purpose:** Collect the $20 verified private-seller listing payment and activate a draft after server validation.
- **Reached via:** tap Publish from Create Listing when phone verified but listing fee unpaid; tap Continue to listing payment from Verification
- **Key UI elements:** One-time $20 listing product card; Billing disclosure text; Pay and publish button; Purchase progress indicator; Purchase error banner
- **States:** loading_product, ready, purchase_in_progress, purchase_validation_pending, purchase_failed, purchase_verified

### Messages Inbox
- **Route:** `messages`
- **Purpose:** Show conversations with readable status, unread badges that clear correctly, and scam quarantine indicators.
- **Reached via:** bottom navigation Messages tab; tap push notification for a message
- **Key UI elements:** Conversation rows with buyer/seller name, listing title, last message preview, unread count, suspicious badge; Delete conversation action; Blocked contact indicator; Empty inbox state
- **States:** loading, empty, error, populated, refreshing

### Conversation
- **Route:** `conversation/{conversationId}`
- **Purpose:** Provide in-app buyer/seller messaging with scam warnings, quarantined content, block/report/delete controls, and read state.
- **Reached via:** tap conversation row on Messages Inbox; tap Contact seller on Listing Detail after verification; tap push notification for a message
- **Key UI elements:** Message timeline; Suspicious message warning card with reason; Quarantined message reveal button; Composer text field; Send button; Block user button; Report conversation button; Delete conversation button
- **States:** loading, not_found, error, active, blocked_by_me, blocked_me, message_sending, message_send_failed, quarantined_message_present

### Report Conversation
- **Route:** `conversation/{conversationId}/report`
- **Purpose:** Capture a structured scam or abuse report for moderation review.
- **Reached via:** tap Report conversation from Conversation
- **Key UI elements:** Reason choices for scam, off-platform contact, fake vehicle report, cashier check, harassment, other; Optional notes field; Submit report button; Cancel button
- **States:** ready, submitting, submitted, error

### Moderation Queue
- **Route:** `moderation`
- **Purpose:** Let admin users review reports, AI-flagged messages, blocked-user disputes, and listing safety issues.
- **Reached via:** admin user opens hidden moderation route from Settings/debug admin entry; tap moderation push notification
- **Key UI elements:** Queue tabs for message reports, AI scam flags, listing reports; Case rows with severity, reason, reporter, created time; Admin-only access denied state
- **States:** loading, access_denied, empty, error, populated

### Moderation Case Detail
- **Route:** `moderation/{caseId}`
- **Purpose:** Let an admin inspect evidence and apply a moderation outcome without relying solely on AI.
- **Reached via:** tap case row on Moderation Queue
- **Key UI elements:** Case summary; Reported messages with AI reasons and scores; Listing snapshot if applicable; User history summary; Actions: dismiss, warn, block user, remove listing, mark resolved
- **States:** loading, not_found, error, open, saving_decision, resolved

## Data model

### UserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | primary key; same as FirebaseAuth uid |
| displayName | `String` |  |
| email | `String?` | nullable if user used phone-only auth |
| phoneNumber | `String?` | nullable until phone verification completes |
| phoneVerified | `Boolean` | true only after Firebase phone auth completes for this uid |
| sellerEligible | `Boolean` | true after phoneVerified and at least one verified listing payment is accepted for a listing draft |
| admin | `Boolean` | gates Moderation Queue and Moderation Case Detail |
| createdAt | `com.google.firebase.Timestamp` |  |
| fcmToken | `String?` | nullable; updated after notification permission/token registration |

### VehicleListing (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; Firestore document id |
| sellerUid | `String` | foreign key to UserProfile.uid |
| status | `String` | draft, pending_payment, active, removed, sold |
| year | `Int` |  |
| make | `String` |  |
| model | `String` |  |
| trim | `String?` | nullable |
| priceUsd | `Int?` | nullable only for drafts; active listings cannot use contact-for-price |
| mileage | `Int` |  |
| transmission | `String` | automatic, manual, other |
| engineSizeLiters | `Double?` | nullable if seller does not know |
| cleanTitle | `Boolean` |  |
| accidentReported | `Boolean` |  |
| frameDamage | `Boolean` |  |
| rentalUse | `Boolean` |  |
| description | `String` |  |
| photoUrls | `List<String>` | Firebase Storage download URLs in display order |
| createdAt | `com.google.firebase.Timestamp` |  |
| updatedAt | `com.google.firebase.Timestamp` |  |
| activatedAt | `com.google.firebase.Timestamp?` | nullable until payment validation succeeds |

### SavedListing (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; composed as uid_listingId |
| uid | `String` | foreign key to UserProfile.uid |
| listingId | `String` | foreign key to VehicleListing.id |
| createdAt | `com.google.firebase.Timestamp` |  |

### Conversation (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; Firestore document id |
| listingId | `String` | foreign key to VehicleListing.id |
| buyerUid | `String` | foreign key to UserProfile.uid |
| sellerUid | `String` | foreign key to UserProfile.uid |
| lastMessageText | `String` | redacted preview if last message is quarantined |
| lastMessageAt | `com.google.firebase.Timestamp` |  |
| buyerUnreadCount | `Int` | set to 0 when buyer opens Conversation |
| sellerUnreadCount | `Int` | set to 0 when seller opens Conversation |
| buyerDeleted | `Boolean` | hides from buyer inbox only |
| sellerDeleted | `Boolean` | hides from seller inbox only |
| blockedByUid | `String?` | nullable; when set, composer is disabled for both parties |
| suspiciousOpenCount | `Int` | number of non-dismissed suspicious messages in this thread |

### Message (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; subcollection under Conversation |
| conversationId | `String` | foreign key to Conversation.id |
| senderUid | `String` | foreign key to UserProfile.uid |
| recipientUid | `String` | foreign key to UserProfile.uid |
| body | `String` |  |
| createdAt | `com.google.firebase.Timestamp` |  |
| deliveryState | `String` | sending, sent, failed |
| readAt | `com.google.firebase.Timestamp?` | nullable until recipient opens the conversation |
| scamScore | `Double` | 0.0 to 1.0 from AI classifier or keyword fallback |
| scamReason | `String?` | nullable; examples: asks_to_leave_app, fake_vehicle_report, cashier_check, phone_number_request, repeated_bot_phrase |
| quarantined | `Boolean` | true when scamScore >= 0.70 or static safety rule matches |

### Report (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; Firestore document id |
| reporterUid | `String` | foreign key to UserProfile.uid |
| reportedUid | `String?` | nullable if report is listing-only |
| conversationId | `String?` | nullable |
| listingId | `String?` | nullable |
| reason | `String` | scam, off_platform_contact, fake_vehicle_report, cashier_check, harassment, other |
| notes | `String` |  |
| createdAt | `com.google.firebase.Timestamp` |  |
| status | `String` | open, dismissed, action_taken |

### PaymentRecord (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; Firestore document id |
| uid | `String` | foreign key to UserProfile.uid |
| listingId | `String` | foreign key to VehicleListing.id |
| productId | `String` | verified_private_listing_20 |
| purchaseToken | `String` | stored after Play Billing purchase; validated by callable backend before listing activation |
| amountUsd | `Int` | 20 |
| status | `String` | pending_validation, verified, rejected, refunded |
| createdAt | `com.google.firebase.Timestamp` |  |
| verifiedAt | `com.google.firebase.Timestamp?` | nullable until backend validation succeeds |

### ModerationCase (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; Firestore document id |
| source | `String` | user_report, ai_scam_flag, listing_report |
| reportId | `String?` | nullable for AI-only cases |
| conversationId | `String?` | nullable |
| messageId | `String?` | nullable |
| listingId | `String?` | nullable |
| severity | `String` | low, medium, high |
| reason | `String` |  |
| status | `String` | open, resolved, dismissed |
| createdAt | `com.google.firebase.Timestamp` |  |
| resolvedByUid | `String?` | nullable until resolved |
| resolution | `String?` | nullable; dismiss, warn, block_user, remove_listing |

## Features

### Verified accounts before seller contact

Require a signed-in, phone-verified buyer before they can start a conversation with a private seller.

- **Answers complaint:** Seller scam flood

- **Screens:** Sign In, Verification, Listing Detail

- **Estimated hours:** 44

**Implementation notes:** Use FirebaseAuth for email sign-in and phone verification. Store UserProfile.phoneVerified=true only after phone auth succeeds for the same uid. In Listing Detail, the Contact seller button first checks FirebaseAuth.currentUser and UserProfile.phoneVerified; if missing, navigate to Sign In or Verification instead of creating a Conversation. Firestore security rules must reject Conversation creation unless request.auth.uid equals buyerUid or sellerUid and the caller’s UserProfile.phoneVerified is true. The seller does not receive any message document until this gate passes.

**Acceptance criteria:**
- A signed-out user tapping Contact seller is routed to Sign In and no Conversation document is created.
- A signed-in user with phoneVerified=false tapping Contact seller is routed to Verification and no Conversation document is created.
- A signed-in user with phoneVerified=true tapping Contact seller creates or opens exactly one Conversation for that buyer, seller, and listing.
- A direct Firestore write attempting to create a Conversation from an unverified uid is denied by security rules.

### Private-party listing creation with photos and required vehicle details

Let verified private sellers create a draft listing with photos, price, mileage, title, accident, damage, rental, transmission, and engine fields.

- **Answers complaint:** baseline parity

- **Screens:** Create Listing, Listing Detail

- **Estimated hours:** 52

**Implementation notes:** Build Create Listing as a form backed by a ViewModel draft state. Use ActivityResultContracts.PickMultipleVisualMedia for image selection so no READ_MEDIA_IMAGES permission is needed. Upload selected images to Firebase Storage path listings/{listingId}/{index}.jpg after the seller taps Save draft or Publish, then store ordered download URLs in VehicleListing.photoUrls. Validate active listings client-side and in Firestore rules: priceUsd must be non-null, mileage >= 0, photoUrls size >= 1, make/model nonblank, transmission one of automatic/manual/other, and cleanTitle/accidentReported/frameDamage/rentalUse explicitly set. Drafts may omit fields but cannot appear in Browse Search.

**Acceptance criteria:**
- Saving a valid draft creates one VehicleListing with status=draft and does not show it in Browse Search.
- Publishing with missing price, mileage, title status, accident status, frame damage, rental use, transmission, or at least one photo shows field-level validation errors.
- Selected photos upload to Firebase Storage and appear in Listing Detail in the same order selected.
- An active listing with priceUsd=null is rejected by client validation and by backend rules.

### $20 verified private-seller listing payment

Charge the report’s stated $20 fee before activating a private seller’s listing.

- **Answers complaint:** baseline parity

- **Screens:** Verification, Create Listing, Listing Payment

- **Estimated hours:** 44

**Implementation notes:** Configure a Google Play one-time in-app product with productId verified_private_listing_20 and price $20. In Listing Payment, query ProductDetails with BillingClient, launch BillingFlow for that product, then send purchaseToken, productId, uid, and draftListingId to a Firebase callable function validateListingPurchase. The callable function validates the Play purchase, writes PaymentRecord.status=verified, sets VehicleListing.status=active and activatedAt, and acknowledges the purchase. The client treats local BillingClient success as pending only; it does not activate the listing until Firestore reflects PaymentRecord.status=verified.

**Acceptance criteria:**
- Listing Payment shows the product id verified_private_listing_20 and a $20 one-time listing disclosure.
- After a successful billing flow but before server validation, the screen shows purchase_validation_pending and the listing remains non-searchable.
- When PaymentRecord.status becomes verified, the listing status changes to active and appears in Browse Search.
- If the callable function rejects the token, the screen shows purchase_failed and the listing remains pending_payment.

### Strict explainable search filters

Enforce private-party search filters exactly and show why listings are excluded when a filter removes all results.

- **Answers complaint:** Filters buyers cannot trust

- **Screens:** Browse Search, Listing Detail

- **Estimated hours:** 56

**Implementation notes:** Store normalized filterable fields on VehicleListing: priceUsd, mileage, transmission, engineSizeLiters, rentalUse, cleanTitle, accidentReported, frameDamage. Browse Search builds a Firestore query for status=active plus the narrowest supported range predicates for price and mileage, then applies remaining predicates client-side to the loaded page: manual transmission, engine range, rentalUse=false, cleanTitle=true, accidentReported=false, frameDamage=false. The Contact-for-price case is impossible for active listings because priceUsd is required; also apply priceUsd != null before rendering. When zero listings remain after client-side strict filtering, show an explanation panel listing each active predicate, such as “Clean title removes listings where cleanTitle is false or unknown.” Do not show exception listings for strict filters.

**Acceptance criteria:**
- Selecting No Accidents excludes every listing with accidentReported=true.
- Selecting Clean Title excludes every listing with cleanTitle=false.
- Selecting No Frame Damage excludes every listing with frameDamage=true.
- Selecting a price range excludes every active listing with priceUsd outside the range and excludes any null price draft from results.
- Selecting mileage above 100,000, such as 100,000 to 180,000, returns listings inside that range instead of capping the upper bound at 100,000.
- When strict filters remove all listings, Browse Search shows filters_applied_no_results with a visible explanation for each active filter.

### Crash-resistant photo browsing

Open and swipe listing photos without freezing or crashing when an image is missing, slow, or malformed.

- **Answers complaint:** Broken browsing release

- **Screens:** Listing Detail

- **Estimated hours:** 28

**Implementation notes:** Use Coil AsyncImage for each photo URL with placeholder and error content. Keep the gallery state in Compose with rememberSaveable currentIndex. Do not decode bitmaps manually; let Coil downsample to the displayed size. Wrap the gallery in a LazyRow or HorizontalPager equivalent using Compose foundation APIs and show a per-photo error tile when Coil returns ErrorResult. Listing Detail must keep all non-photo content visible even if some or all photos fail to load. ViewModel loading of listing metadata and image rendering errors are separate states so a bad image cannot navigate away or restart the screen.

**Acceptance criteria:**
- Opening a listing with three valid photoUrls shows the first photo and allows swiping through all three.
- Opening a listing with one invalid photo URL shows an error tile for that photo while price, mileage, and Contact seller remain visible.
- Rotating the device or recreating the activity preserves the currently selected photo index.
- A Coil image load failure does not crash the app process and does not pop the navigation stack.

### Saved listings

Let buyers save and unsave active listings and view them later.

- **Answers complaint:** baseline parity

- **Screens:** Browse Search, Listing Detail, Saved Listings

- **Estimated hours:** 24

**Implementation notes:** When the Save button is tapped, write SavedListing document id {uid}_{listingId} with uid, listingId, and createdAt. When unsaved, delete that document. Browse Search and Listing Detail observe the specific SavedListing document to render saved state. Saved Listings queries saved documents for the current uid ordered by createdAt descending, then resolves active VehicleListing documents; if a listing was removed, show no card for it and allow the orphan SavedListing to be deleted silently by the repository.

**Acceptance criteria:**
- Tapping Save on Listing Detail creates exactly one SavedListing document for the current uid and listingId.
- Tapping Save twice does not create duplicate saved rows because the document id is deterministic.
- Tapping Unsave removes the SavedListing and immediately updates Browse Search, Listing Detail, and Saved Listings state.
- Saved Listings shows empty when the current user has no saved active listings.

### Reliable inbox, unread badges, and delete behavior

Provide stable messaging state where unread counts clear on open, deleted conversations stay hidden, and refreshes do not flash stale rows.

- **Answers complaint:** Messaging and account bugs

- **Screens:** Messages Inbox, Conversation

- **Estimated hours:** 68

**Implementation notes:** Store unread counts on Conversation as buyerUnreadCount and sellerUnreadCount. When a user opens Conversation, run a Firestore transaction: set that user’s unread count to 0 and set readAt on unread Message documents where recipientUid equals current uid. Message sending uses a batched write to create Message, update Conversation.lastMessageText/lastMessageAt, increment the recipient unread count, and clear the sender deleted flag so sending reopens the thread for the sender. Delete conversation is per-user: set buyerDeleted or sellerDeleted true; queries filter out conversations where the current user’s deleted flag is true. The inbox ViewModel uses a single snapshot listener and diffed immutable UI state so refreshes show a small refreshing indicator without clearing the list first.

**Acceptance criteria:**
- Receiving two unread messages increments the recipient’s inbox badge to 2.
- Opening the Conversation sets that recipient’s unread badge to 0 without requiring the recipient to reply.
- Deleting a conversation hides it from the deleting user’s Messages Inbox but does not delete it for the other participant.
- A new incoming message in a previously deleted conversation clears the recipient’s deleted flag and makes the conversation visible again.
- Refreshing the inbox while data is already populated does not show the empty state between snapshots.

### AI scam triage with link and phone-number friction

Score inbound messages for scam patterns, quarantine high-risk content, and show the seller a clear reason before they open it.

- **Answers complaint:** Seller scam flood

- **Screens:** Messages Inbox, Conversation, Moderation Queue, Moderation Case Detail

- **Estimated hours:** 76

**Implementation notes:** All message sends go through a Firebase callable function sendMessage rather than direct client writes. The function first applies static regex rules for URLs, phone numbers, cashier-check phrases, vehicle-report phrases, and leave-the-app language. It then calls the AI gateway LLM with the message body and a fixed classification prompt requesting JSON fields scamScore and scamReason from the allowed reasons asks_to_leave_app, fake_vehicle_report, cashier_check, phone_number_request, repeated_bot_phrase, none. If the AI call fails, use the static rule score only. Messages with scamScore >= 0.70 or any URL/phone static rule are written with quarantined=true, Conversation.suspiciousOpenCount incremented, and lastMessageText set to “Suspicious message hidden”. Conversation shows a warning card with scamReason and a Reveal message button; revealing does not mark the content safe or notify the sender.

**Acceptance criteria:**
- A message containing a phone number is quarantined and shown with reason phone_number_request or asks_to_leave_app before the body is revealed.
- A message containing a third-party vehicle-report link is quarantined and shown with reason fake_vehicle_report.
- A message mentioning cashier’s check is quarantined and shown with reason cashier_check.
- If the AI callable returns an error, a URL message is still quarantined by static rules.
- The sender sees the message as sent; the recipient inbox preview says “Suspicious message hidden” instead of the raw suspicious body.

### Block, report, delete, and moderation controls

Give users explicit controls to block, report, delete conversations, and route suspicious cases to an admin review queue.

- **Answers complaint:** Messaging and account bugs

- **Screens:** Conversation, Report Conversation, Messages Inbox, Moderation Queue, Moderation Case Detail

- **Estimated hours:** 56

**Implementation notes:** Conversation block action sets Conversation.blockedByUid=current uid in a transaction and disables the composer for both participants. Report Conversation writes a Report and creates a ModerationCase with source=user_report, status=open, reason, and severity=high for scam/cashier_check/fake_vehicle_report or medium otherwise. Delete only sets the per-user deleted flag described in the messaging feature. Admin actions on Moderation Case Detail update ModerationCase.status/resolution and, depending on action, set Conversation.blockedByUid, set VehicleListing.status=removed, or leave content unchanged. Access to Moderation Queue and Case Detail is allowed only when UserProfile.admin=true.

**Acceptance criteria:**
- After user A blocks a conversation, both user A and user B see the composer disabled in that conversation.
- A blocked user cannot create a new Message in the blocked Conversation through the callable sendMessage function.
- Submitting a report creates one Report and one open ModerationCase linked to the conversation.
- A non-admin user opening route moderation sees access_denied and no case data.
- An admin resolving a case as remove_listing sets the linked VehicleListing.status to removed and it no longer appears in Browse Search.

### Push notifications for trusted and quarantined messages

Notify users of new messages while distinguishing normal messages from suspicious hidden messages.

- **Answers complaint:** Messaging and account bugs

- **Screens:** Messages Inbox, Conversation

- **Estimated hours:** 32

**Implementation notes:** Request POST_NOTIFICATIONS on Android 13+ only after the user first opens Messages Inbox or sends a message. Store the current FCM token in UserProfile.fcmToken. The sendMessage callable sends an FCM notification to the recipient after writing Firestore data. For normal messages, notification title is the sender display name and body is the message preview. For quarantined messages, title is “Suspicious message hidden” and body is the scam reason label, never the raw message. Tapping the notification deep-links to conversation/{conversationId}. If permission is denied, in-app unread badges still update through Firestore listeners.

**Acceptance criteria:**
- On Android 13+, the app requests POST_NOTIFICATIONS only after the user enters a messaging flow, not on first launch.
- A normal inbound message produces a notification that opens the correct Conversation route when tapped.
- A quarantined inbound message notification does not display the raw message body.
- If notification permission is denied, receiving a message still increments the Firestore-backed unread badge in Messages Inbox.

## Store listing

- **Title:** SafeParty Cars
- **Short description:** Verified private car selling with scam-aware messaging.
- **Category:** Auto & Vehicles
- **Keywords:** private car sale, used cars, sell my car, verified seller, scam protection, car marketplace, clean title, no accidents, manual transmission, saved cars
- **Icon prompt:** Create a modern Android app icon for a safe private-party car selling app: a simple shield with a check mark in front of a compact car silhouette, deep green primary color #1F6F5B, warm off-white background #F7F4EE, rounded Material style, high contrast, no text, no brand names, centered glyph, suitable for Play Store launcher icon.

**Long description:**

SafeParty Cars is a focused private-party car marketplace for sellers and buyers who want safer contact, not a giant dealer inventory clone.

List a private-party vehicle, verify your account, and keep conversations inside the app. Buyers must be verified before contacting sellers, suspicious messages can be hidden with clear reasons, and users can block, report, or delete conversations without replying to a scammer.

Search is strict and explainable: if you choose clean title, no accidents, no frame damage, manual transmission, mileage range, or price range, results must match those filters instead of quietly showing exceptions.

Free to browse and save listings. Private sellers pay a one-time $20 verified listing fee when publishing a vehicle.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://safepartycars.example.com/privacy (privacy claims verified: no)
- **Data collected:** Email address if provided for sign-in; Phone number and phone verification status; Display name; Vehicle listing details including price, mileage, title status, accident status, frame damage status, rental use, transmission, engine size, description, and photos; Saved listing IDs; In-app messages and conversation metadata; Block and report records; Google Play purchase token and listing payment status; Firebase Cloud Messaging token for notifications; AI scam score and reason for messages

## Test plan

### 1. Seller scam flood: unverified people can reach private sellers (instrumented)

1. Launch the app with a Firebase test user whose UserProfile.phoneVerified is false.
2. Seed Firestore with one active VehicleListing owned by a different seller uid.
3. Navigate to Browse Search and tap the seeded listing.
4. Tap Contact seller on Listing Detail.
5. Query the test Firestore conversations collection for buyerUid equal to the test user and listingId equal to the seeded listing.

**Expected:** The app navigates to Verification and the Firestore query returns zero Conversation documents.

### 2. Seller scam flood: fake report-site and off-platform messages waste seller time (unit)

1. Call the message classification repository with body: "Before I buy, text me at 555-222-1111 and get the report from cheapvinreport.example".
2. Use a fake AI gateway response with scamScore 0.92 and scamReason fake_vehicle_report.
3. Inspect the Message write model returned by the repository.

**Expected:** The write model has quarantined=true, scamScore >= 0.70, scamReason=fake_vehicle_report, and inbox preview text equal to "Suspicious message hidden".

### 3. Seller scam flood: AI outage should not leave obvious scam links unfiltered (unit)

1. Configure the fake AI gateway to throw an exception.
2. Call the message classification repository with body: "Can we leave the app? Use my phone 555-101-1212".
3. Inspect the fallback classification result.

**Expected:** The result is quarantined=true with a non-null scamReason matching phone_number_request or asks_to_leave_app.

### 4. Messaging and account bugs: unread badges do not clear (instrumented)

1. Seed a Conversation where the signed-in user is sellerUid and sellerUnreadCount is 2.
2. Seed two unread Message documents in that conversation with recipientUid equal to the signed-in user and readAt null.
3. Navigate to Messages Inbox and verify the row badge shows 2.
4. Tap the conversation row.
5. Wait for the Conversation screen to finish loading.
6. Navigate back to Messages Inbox.

**Expected:** The same conversation row shows no unread badge, and both seeded Message documents have non-null readAt values.

### 5. Messaging and account bugs: blocked users still linger or can continue messaging (instrumented)

1. Seed an active Conversation between signed-in user A and user B.
2. Open Conversation as user A and tap Block user.
3. Confirm the block action.
4. Attempt to send another message from the composer as user A.
5. Invoke the sendMessage callable test double as user B for the same conversation.

**Expected:** The Conversation screen shows blocked_by_me with a disabled composer, and the callable rejects user B’s send attempt for the blocked conversation.

### 6. Messaging and account bugs: conversations cannot be deleted (instrumented)

1. Seed two conversations for the signed-in user.
2. Open Messages Inbox and swipe/delete the first conversation row or tap its Delete action.
3. Wait for the inbox snapshot to update.
4. Query the first Conversation document.

**Expected:** Messages Inbox still shows the second conversation, no longer shows the deleted one for the signed-in user, and the deleted Conversation document still exists with that user’s deleted flag set to true.

### 7. Filters buyers cannot trust: damaged cars appear when clean/no accident/no frame damage filters are selected (unit)

1. Create four VehicleListing objects: one clean listing, one with accidentReported=true, one with cleanTitle=false, and one with frameDamage=true.
2. Apply filters cleanTitle=true, accidentReported=false, frameDamage=false to the repository’s strictFilter function.
3. Collect the resulting listing ids.

**Expected:** Only the clean listing id remains in the result set.

### 8. Filters buyers cannot trust: Contact Dealer For Price appears inside a price range (unit)

1. Create three VehicleListing objects: priceUsd=15000, priceUsd=25000, and priceUsd=null.
2. Apply price range 10000 through 20000 to the strictFilter function.
3. Collect the resulting listing ids.

**Expected:** Only the listing with priceUsd=15000 remains; the null price listing is excluded.

### 9. Filters buyers cannot trust: mileage ranges above 100,000 are not supported (unit)

1. Create listings with mileage 90000, 125000, and 175000.
2. Apply mileage range 100000 through 180000 to the strictFilter function.
3. Collect the resulting mileage values.

**Expected:** The result contains 125000 and 175000 and excludes 90000.

### 10. Broken browsing release: opening vehicle photos freezes or crashes (instrumented)

1. Seed an active VehicleListing with photoUrls containing one valid HTTPS image URL and one invalid URL string.
2. Navigate from Browse Search to Listing Detail for that listing.
3. Swipe from the first photo to the second photo.
4. Press back to Browse Search.

**Expected:** The app process remains alive, Listing Detail shows an error tile for the invalid photo, vehicle details remain visible, and back navigation returns to Browse Search.

### 11. baseline parity: $20 verified private-seller listing payment activates listing only after validation (manual)

1. Install an internal test build signed with the upload key.
2. Sign in with a license-test Google account and complete phone verification.
3. Create a listing draft with one photo and all required vehicle fields.
4. Tap Publish and complete the Google Play test purchase for product verified_private_listing_20.
5. Before backend validation completes, open Browse Search in another device and search for the listing.
6. After PaymentRecord.status becomes verified, refresh Browse Search.

**Expected:** The listing is absent before validation and visible as an active listing after PaymentRecord.status is verified.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit -keypass changeit -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=SafeParty Cars,O=Solo Builder,C=US"
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/release-upload.jks -Pandroid.injected.signing.store.password=changeit -Pandroid.injected.signing.key.alias=upload -Pandroid.injected.signing.key.password=changeit
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
