# RoadLedger — build spec

A simple automatic mileage tracker with one flat $5/month plan, self-service billing links, one-tap trip classification, and tax-ready exports.

## 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 the optional api_access endpoints), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** Mileage Tracker & Log - MileIQ
- **Package id:** `com.mobiledatalabs.mileiq`
- **Google Play:** https://play.google.com/store/apps/details?id=com.mobiledatalabs.mileiq
- **appy.fyi report:** https://appy.fyi/report/com.mobiledatalabs.mileiq
- **Category:** Finance

## Overview

- **Working name:** RoadLedger (trademark cleared: no)
- **Package id:** `com.appyfyi.roadledger`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 6 weeks
- **Pricing:** subscription, $5 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS`, `INTERNET`

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

- No annual plan, introductory teaser price, usage-based tier, or automatic price-escalation messaging in v1.
- No team accounts, accountant portal, fleet management, web dashboard, or multi-device sync in v1.
- No receipt scanning, expense tracking, tax advice, or deduction-value calculation beyond mileage totals in v1.
- No generative AI, route prediction AI, or cloud trip processing in v1.
- No manual refund processing inside the app; v1 opens Google Play's subscription-management and refund-request flows because Google Play controls those transactions.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.12.01
- **Gradle:** 8.10.2

| Purpose | Gradle coordinate |
| --- | --- |
| Compose host Activity integration | `androidx.activity:activity-compose:1.9.3` |
| Compose UI runtime and foundation widgets | `androidx.compose.ui:ui:1.7.6` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.1` |
| Compose preview support | `androidx.compose.ui:ui-tooling-preview:1.7.6` |
| ViewModel state integration for Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7` |
| Lifecycle-aware coroutines for foreground service and view models | `androidx.lifecycle:lifecycle-runtime-ktx:2.8.7` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.5` |
| Local trip database runtime | `androidx.room:room-runtime:2.6.1` |
| Room coroutine and Flow APIs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor for entities and DAOs | `androidx.room:room-compiler:2.6.1` |
| Kotlin Symbol Processing for Room compiler | `com.google.devtools.ksp:symbol-processing-api:2.0.21-1.0.28` |
| High-accuracy GPS and fused location updates | `com.google.android.gms:play-services-location:21.3.0` |
| Monthly subscription entitlement and Google Play purchase handling | `com.revenuecat.purchases:purchases:8.11.0` |

## Design system

- **Primary color:** `#1B6E5C`
- **Background color:** `#F7FAF8`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `car` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light theme uses #F7FAF8 background, #1B6E5C primary, #0F1F1B text. Dark theme uses #0B1412 background, #69D3B8 primary, #E3F1EC text. Trip category chips use #1B6E5C for Business, #5B6470 for Personal, and #D97706 for Unclassified.

## Screens

### Permission Onboarding
- **Route:** `onboarding`
- **Purpose:** Explain why continuous location is needed and request the minimum permissions required for automatic trip detection.
- **Reached via:** app launch when ACCESS_FINE_LOCATION is not granted; tap Enable tracking from Trip List when location permission is missing
- **Key UI elements:** App logo and title; Plain-language explanation: automatic mileage tracking needs location while driving; Primary button: Enable location; Secondary button: Not now; Notification-permission request row shown on Android 13+; Permission-denied help card with button opening system app settings
- **States:** location_not_requested, location_granted_notification_pending, all_required_permissions_granted, permission_denied_can_retry, permission_denied_open_settings_required

### Paywall
- **Route:** `paywall`
- **Purpose:** Show the flat $5/month subscription offer and start purchase or restore flow.
- **Reached via:** app launch when subscription entitlement is inactive and permissions are already granted; tap Upgrade from Settings; tap Export when entitlement is inactive
- **Key UI elements:** Headline: Flat $5/month mileage tracking; Three bullet promises: auto-detect drives, one-tap business/personal, CSV/PDF export; Price label sourced from RevenueCat monthly package when available, falling back to $5/month display if offering load fails; Subscribe button; Restore purchases button; Link: Manage or cancel anytime in Google Play
- **States:** loading_offering, offering_loaded, offering_error_with_retry, purchase_in_progress, purchase_error, entitlement_active

### Trip List
- **Route:** `trips`
- **Purpose:** Main dashboard showing detected trips, tracking status, monthly mileage totals, and unclassified trips.
- **Reached via:** app launch after permissions and active subscription are confirmed; tap Back from Trip Detail; tap Back from Export; tap Trips tab if bottom navigation is added later
- **Key UI elements:** Tracking status card: Ready, Detecting drive, Recording drive, or Permission needed; Current month total business miles; Current month total personal miles; Filter chips: All, Unclassified, Business, Personal; LazyColumn of trip cards with date, start/end time, distance, and category chip; Floating action button: Export
- **States:** loading, empty_no_trips, populated, permission_missing, subscription_inactive, database_error

### Trip Detail
- **Route:** `trip/{tripId}`
- **Purpose:** Review a single detected trip and classify it as Business or Personal with one tap.
- **Reached via:** tap a trip card on Trip List; tap a newly detected trip notification
- **Key UI elements:** Trip date and time range; Distance in miles to one decimal place; Start and end coordinate summary rounded to 4 decimal places; Segment count; Business button; Personal button; Unclassified status chip; Save confirmation snackbar after classification
- **States:** loading, trip_found, trip_not_found, saving_category, save_error

### Export
- **Route:** `export`
- **Purpose:** Generate IRS-ready CSV and PDF mileage reports for a selected date range.
- **Reached via:** tap Export floating action button on Trip List; tap Export from Settings
- **Key UI elements:** Start date field; End date field; Category selector: Business only, Personal only, All; Summary card with total trips and total miles; Generate CSV button; Generate PDF button; Share sheet launcher after file generation
- **States:** loading_summary, empty_range, ready, generating_csv, generating_pdf, generation_error, subscription_inactive

### Settings
- **Route:** `settings`
- **Purpose:** Provide subscription status, cancellation/refund links, app version, and permission status.
- **Reached via:** tap Settings icon from Trip List; tap Manage or cancel anytime link on Paywall
- **Key UI elements:** Subscription status row: Active or Inactive; Flat price row: $5/month; Button: Manage or cancel subscription; Button: Request refund through Google Play; Location permission status row; Button: Open Android app settings; Privacy policy link placeholder
- **States:** loading_subscription, active_subscription, inactive_subscription, billing_status_error, permission_granted, permission_missing

## Data model

### Trip (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| startedAtEpochMillis | `Long` | UTC epoch milliseconds when the trip crossed the driving threshold |
| endedAtEpochMillis | `Long` | UTC epoch milliseconds when stop criteria finalized the trip |
| distanceMeters | `Double` | sum of accepted TripPoint haversine segment distances |
| category | `String` | one of UNCLASSIFIED, BUSINESS, PERSONAL |
| startLatitude | `Double` | first accepted point latitude |
| startLongitude | `Double` | first accepted point longitude |
| endLatitude | `Double` | last accepted point latitude |
| endLongitude | `Double` | last accepted point longitude |
| createdAtEpochMillis | `Long` | UTC epoch milliseconds when row was inserted |
| updatedAtEpochMillis | `Long` | UTC epoch milliseconds when category or computed distance last changed |

### TripPoint (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| tripId | `Long` | foreign key to Trip.id, cascade delete |
| sequenceIndex | `Int` | zero-based order within the trip |
| recordedAtEpochMillis | `Long` | UTC epoch milliseconds from Location.time |
| latitude | `Double` |  |
| longitude | `Double` |  |
| speedMetersPerSecond | `Float` | nullable if Android Location.hasSpeed is false |
| accuracyMeters | `Float` | nullable if Android Location.hasAccuracy is false |

## Features

### Permission and tracking onboarding

Guides the user through location and notification permissions required for automatic mileage tracking.

- **Answers complaint:** baseline parity

- **Screens:** Permission Onboarding, Trip List

- **Estimated hours:** 24

**Implementation notes:** On first launch, route to onboarding if ContextCompat.checkSelfPermission for ACCESS_FINE_LOCATION is not GRANTED. Request ACCESS_FINE_LOCATION using ActivityResultContracts.RequestPermission. On Android 13 and later, request POST_NOTIFICATIONS separately before starting the foreground tracking service. If the user denies location once, show an inline retry explanation; if shouldShowRequestPermissionRationale is false after denial, show a button that fires Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) with Uri.parse("package:" + packageName). After both required runtime permissions are granted, navigate to Paywall if RevenueCat entitlement is inactive or Trip List if active.

**Acceptance criteria:**
- A fresh install with no location permission opens the onboarding route instead of the trip list.
- Tapping Enable location launches the Android runtime location permission dialog.
- If location is granted and notification permission is granted or not required, the app leaves onboarding automatically.
- If location is permanently denied, the screen shows an Open Android app settings button and does not repeatedly launch the permission dialog.

### GPS drive auto-detection and mileage calculation

Detects drives from fused GPS updates, records route points locally, and computes trip mileage.

- **Answers complaint:** Drive-detection accuracy

- **Screens:** Trip List, Trip Detail

- **Estimated hours:** 96

**Implementation notes:** Implement a foreground service named DriveTrackingService using FusedLocationProviderClient.requestLocationUpdates with a LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 15000L), setMinUpdateDistanceMeters(25f), and setMaxUpdateDelayMillis(30000L). Maintain an in-memory state machine: IDLE, MAYBE_DRIVING, RECORDING, MAYBE_STOPPED. Ignore any Location with accuracyMeters > 75. From IDLE enter MAYBE_DRIVING when either speedMetersPerSecond >= 6.7f or distance from the last accepted idle point is >= 200 meters within 120 seconds. Enter RECORDING only if the driving condition persists for at least 120 seconds; create a Trip row with category UNCLASSIFIED and insert buffered points. While RECORDING, append accepted points and update distance using the haversine formula with Earth radius 6,371,000 meters. Enter MAYBE_STOPPED when speedMetersPerSecond < 2.0f and displacement over the last 5 minutes is < 50 meters. Finalize the trip when MAYBE_STOPPED lasts 5 minutes. Discard the candidate trip instead of saving if total distance is under 804.672 meters or duration is under 120 seconds; this filters fabricated short trips and GPS jitter. Convert meters to miles for display and export by dividing by 1609.344 and rounding to one decimal place.

**Acceptance criteria:**
- A simulated 10-minute route with accepted points moving at 12 m/s creates exactly one Trip row.
- A simulated stationary sequence with random jitter under 50 meters for 15 minutes creates no Trip row.
- A simulated movement lasting 60 seconds and 300 meters is discarded because duration is below 120 seconds.
- A recorded trip's distanceMeters equals the sum of haversine distances between accepted points within a 1% tolerance.
- The Trip List tracking card changes from Ready to Recording drive while the service is in RECORDING state.

### Trip review and one-tap classification

Shows detected trips and lets the user classify each trip as Business or Personal with one tap.

- **Answers complaint:** Match the core loop exactly, nothing more.

- **Screens:** Trip List, Trip Detail

- **Estimated hours:** 40

**Implementation notes:** Trip List observes Room Flow<List<Trip>> ordered by startedAtEpochMillis descending. Display category filter chips backed by a ViewModel StateFlow with values ALL, UNCLASSIFIED, BUSINESS, PERSONAL. Trip Detail loads Trip by tripId from the route. The Business and Personal buttons call TripDao.updateCategory(tripId, category, updatedAtEpochMillis) inside Dispatchers.IO, then update visible state immediately from the Room Flow. Category labels must be exactly UNCLASSIFIED, BUSINESS, and PERSONAL in storage so CSV/PDF export can use the same values. Do not add custom category creation in v1.

**Acceptance criteria:**
- The Trip List empty state appears when the Trip table has zero rows.
- A trip inserted with category UNCLASSIFIED appears under the Unclassified filter.
- Tapping Business on Trip Detail changes the Room row category to BUSINESS and the Trip List chip to Business without restarting the app.
- Tapping Personal on Trip Detail changes the Room row category to PERSONAL and removes it from the Unclassified filter.
- The Trip Detail route trip/{tripId} shows trip_not_found state for a nonexistent id.

### Tax-ready CSV and PDF export

Exports selected trips for a date range as CSV or PDF files containing dates, times, mileage, and classification.

- **Answers complaint:** Match the core loop exactly, nothing more.

- **Screens:** Export, Trip List

- **Estimated hours:** 40

**Implementation notes:** Export screen queries Room for trips where startedAtEpochMillis >= selectedStartInclusive and startedAtEpochMillis < selectedEndExclusive, optionally filtering category BUSINESS, PERSONAL, or all. CSV generation writes to cacheDir/exports/roadledger-mileage-YYYY-MM-DD-to-YYYY-MM-DD.csv using UTF-8 with header Date,Start Time,End Time,Category,Miles,Start Latitude,Start Longitude,End Latitude,End Longitude. Each row formats local dates/times with java.time DateTimeFormatter.ISO_LOCAL_DATE and HH:mm, miles rounded to one decimal. PDF generation uses android.graphics.pdf.PdfDocument with US Letter size 612x792 points, title 'Mileage Report', date range, category filter, total miles, and a table of the same row data; paginate after 32 rows per page. After file creation, expose the file through FileProvider and launch Intent.createChooser(Intent(Intent.ACTION_SEND).setType('text/csv' or 'application/pdf').putExtra(Intent.EXTRA_STREAM, uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION), 'Share mileage report').

**Acceptance criteria:**
- For three Business trips in the selected month, Business-only CSV contains one header row and three data rows.
- CSV miles values are rounded to one decimal and use a dot decimal separator.
- An empty selected date range shows empty_range state and disables Generate CSV and Generate PDF.
- A PDF generated for 40 trips contains at least two pages.
- After CSV or PDF generation, Android's share sheet is launched with a content:// URI from the app FileProvider.

### Flat $5/month subscription and self-service billing links

Unlocks the tracker with a single monthly subscription and provides clear Google Play cancellation and refund entry points.

- **Answers complaint:** Repeated, steep price hikes; Billing and refund friction

- **Screens:** Paywall, Settings, Trip List, Export

- **Estimated hours:** 40

**Implementation notes:** Configure RevenueCat with one entitlement id 'pro' and one monthly product id 'roadledger_monthly_5'. On app start and Paywall load, call Purchases.sharedInstance.getCustomerInfo and treat entitlement 'pro' active as subscribed. Load offerings with Purchases.sharedInstance.getOfferings; show the monthly package price string from RevenueCat, but the only configured package for v1 must be monthly and the visible promise text must say 'Flat $5/month'. Subscribe button calls purchaseWith for the monthly package and, on success, routes to Trip List. Restore purchases calls restorePurchases and re-checks the 'pro' entitlement. Settings Manage or cancel opens Intent(Intent.ACTION_VIEW, Uri.parse('https://play.google.com/store/account/subscriptions?package=com.appyfyi.roadledger')). Settings Request refund opens Intent(Intent.ACTION_VIEW, Uri.parse('https://support.google.com/googleplay/workflow/9813244')). Do not implement annual subscriptions, intro prices, coupons, or in-app refund adjudication.

**Acceptance criteria:**
- Paywall displays a single monthly offer and no annual plan.
- When RevenueCat customer info contains active entitlement id pro, app launch routes past Paywall to Trip List.
- When entitlement pro is inactive, tapping Export routes to Paywall instead of generating a file.
- Tapping Manage or cancel subscription opens a browser or Play Store intent whose URI contains /store/account/subscriptions and package=com.appyfyi.roadledger.
- Tapping Request refund through Google Play opens the Google Play refund workflow URL.

## Store listing

- **Title:** RoadLedger Mileage
- **Short description:** Automatic mileage tracking for a flat $5/month.
- **Category:** Business
- **Keywords:** mileage tracker, automatic mileage, business miles, trip log, tax mileage, GPS mileage, CSV mileage export, PDF mileage report
- **Icon prompt:** Create a modern Android app icon for a mileage tracker named RoadLedger: rounded square background in deep teal #1B6E5C, centered simple white car silhouette with a subtle road line beneath it, flat vector style, high contrast, no text, no numbers, no gradients, clean Material Design feel, suitable at small launcher-icon sizes.

**Long description:**

RoadLedger is a simple automatic mileage tracker built around a clear promise: one flat $5/month plan for the core loop drivers actually need.

Track drives automatically with GPS, review detected trips, classify each trip as Business or Personal with one tap, and export CSV or PDF mileage reports for tax records.

What v1 includes:
• Automatic drive detection
• Local trip history
• Business, Personal, and Unclassified trip views
• Monthly mileage totals
• CSV export
• PDF mileage report export
• Google Play self-service subscription management and refund links

No annual upsell, no fleet dashboard, no expense clutter, and no generative AI. Just mileage tracking at a straightforward monthly price.

## Legal

- **Regulated category:** legal_evidence
- **Privacy policy URL:** https://appyfyi.com/privacy/roadledger (privacy claims verified: no)
- **Data collected:** Precise location while tracking drives; Trip start and end timestamps; Trip route coordinates stored locally on device; Trip distance; Trip classification as Business, Personal, or Unclassified; Google Play subscription purchase status via RevenueCat

## Test plan

### 1. Drive-detection accuracy: fabricated trips from GPS jitter (unit)

1. Instantiate the drive detection state machine with an in-memory fake TripRepository.
2. Feed 60 fake Location objects over 15 minutes centered on latitude 37.7749 and longitude -122.4194.
3. For each fake Location, vary latitude and longitude so the point remains within 40 meters of the center, set accuracy to 20 meters, and set speed to 0.5 m/s.
4. Advance fake time by 15 seconds between points.
5. Flush pending state-machine timers.

**Expected:** The repository contains zero Trip rows and the final detector state is IDLE.

### 2. Drive-detection accuracy: missed real drive (unit)

1. Instantiate the drive detection state machine with an in-memory fake TripRepository.
2. Feed 48 fake Location objects over 12 minutes along a straight route, 180 meters apart, with speed 12 m/s and accuracy 15 meters.
3. Advance fake time by 15 seconds between points.
4. Then feed 24 stationary Location objects over 6 minutes within 20 meters of the final point, speed 0.3 m/s, accuracy 15 meters.
5. Flush pending state-machine timers.

**Expected:** Exactly one Trip row exists, its duration is at least 12 minutes, its category is UNCLASSIFIED, and its distanceMeters is greater than 8000.

### 3. Match the core loop exactly: one-tap business/personal classification (instrumented)

1. Launch the app with a test database containing one Trip row with category UNCLASSIFIED.
2. Navigate to route trips.
3. Tap the trip card for that row.
4. Tap the Business button on Trip Detail.
5. Press the system back button to return to Trip List.
6. Tap the Business filter chip.

**Expected:** The trip is visible under the Business filter, its category chip reads Business, and the Room row category is BUSINESS.

### 4. Match the core loop exactly: IRS-ready export (instrumented)

1. Launch the app with an active test entitlement and a test database containing three BUSINESS trips in January 2026 and one PERSONAL trip in January 2026.
2. Navigate to route export.
3. Set start date to 2026-01-01 and end date to 2026-01-31.
4. Select Business only.
5. Tap Generate CSV.
6. Intercept the ACTION_SEND intent with Espresso Intents.

**Expected:** The shared CSV file contains the header Date,Start Time,End Time,Category,Miles,Start Latitude,Start Longitude,End Latitude,End Longitude and exactly three BUSINESS data rows.

### 5. Repeated, steep price hikes (instrumented)

1. Launch the app with RevenueCat mocked to return inactive entitlement and one monthly offering for product roadledger_monthly_5.
2. Navigate to route paywall.
3. Read all visible text nodes on the Paywall screen.

**Expected:** The Paywall shows Flat $5/month mileage tracking, shows no annual price, and shows no second subscription tier.

### 6. Billing and refund friction (instrumented)

1. Launch the app and navigate to route settings.
2. Tap Manage or cancel subscription.
3. Capture the outgoing ACTION_VIEW intent.
4. Return to the app.
5. Tap Request refund through Google Play.
6. Capture the outgoing ACTION_VIEW intent.

**Expected:** The first intent URI is https://play.google.com/store/account/subscriptions?package=com.appyfyi.roadledger and the second intent URI is https://support.google.com/googleplay/workflow/9813244.

### 7. Baseline parity: foreground automatic tracking on a real drive (manual)

1. Install a release build on a physical Android phone with mobile data enabled.
2. Grant location and notification permissions.
3. Activate a test subscription entitlement.
4. Open Trip List and confirm tracking status is Ready.
5. Drive for at least 10 minutes over at least 3 miles.
6. Park and remain stationary for at least 6 minutes.
7. Open Trip List.

**Expected:** A new unclassified trip appears with start/end times matching the drive within 5 minutes and displayed distance within 15% of the vehicle odometer distance.

## Build instructions

```sh
set -e
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore roadledger-upload.jks -storepass changeit123 -keypass changeit123 -alias roadledger_upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=RoadLedger Upload, OU=Android, O=AppyFyi, L=San Francisco, ST=CA, C=US"
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/roadledger-upload.jks -Pandroid.injected.signing.store.password=changeit123 -Pandroid.injected.signing.key.alias=roadledger_upload -Pandroid.injected.signing.key.password=changeit123
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
- `regulated_category_go_no_go`
