# PermitFair DMV Prep — build spec

A DMV permit prep app that lets a teen finish a complete practice test before seeing any paywall and offers a single affordable lifetime unlock instead of a weekly subscription.

## 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:** Zutobi: Permit & Driving Prep
- **Package id:** `com.driverlicenseapp`
- **Google Play:** https://play.google.com/store/apps/details?id=com.driverlicenseapp
- **appy.fyi report:** https://appy.fyi/report/com.driverlicenseapp
- **Category:** Education

## Overview

- **Working name:** PermitFair DMV Prep (trademark cleared: no)
- **Package id:** `fyi.appy.permitfairdmvprep`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 5 weeks
- **Pricing:** one-time purchase, $4.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** none

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

- No weekly, monthly, or trial subscription in v1.
- No nationwide 50-state rollout in v1; scope is 2-3 launch-state content packs sourced from public DMV handbooks.
- No AI-generated questions, chat tutor, or image generation.
- No accounts, cloud sync, leaderboards, or server-side progress backup.
- No guarantee of passing the permit test; the app is study prep based on public handbook material.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.1.20
- **Compose BOM:** 2025.05.00
- **Gradle:** 8.14.2

| Purpose | Gradle coordinate |
| --- | --- |
| Android activity integration for Jetpack Compose screens | `androidx.activity:activity-compose:1.10.1` |
| Material 3 Compose components and theming | `androidx.compose.material3:material3:1.3.2` |
| Compose navigation graph for screen routing | `androidx.navigation:navigation-compose:2.9.0` |
| Lifecycle-aware ViewModels used by Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1` |
| Lifecycle runtime Kotlin extensions for app startup and coroutine scopes | `androidx.lifecycle:lifecycle-runtime-ktx:2.9.1` |
| Room local database runtime for bundled content, progress, attempts, and entitlement cache | `androidx.room:room-runtime:2.7.1` |
| Room coroutine and Flow support | `androidx.room:room-ktx:2.7.1` |
| Room annotation processor for DAO and entity code generation | `androidx.room:room-compiler:2.7.1` |
| Kotlin serialization for parsing bundled public-handbook JSON content assets | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1` |
| Google Play one-time in-app purchase integration | `com.android.billingclient:billing-ktx:7.1.1` |
| Core Android Kotlin extensions | `androidx.core:core-ktx:1.16.0` |

## Design system

- **Primary color:** `#1E6B5E`
- **Background color:** `#FAF8F2`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `car` (regular weight)
- **Theme notes:** Use Material 3 dynamic color disabled so screenshots are deterministic. Light theme uses #FAF8F2 background, #1E6B5E primary, dark theme uses #101815 background with the same primary shifted only by Material color roles. Quiz answer cards are white in light mode and #1B211F in dark mode; correct answers use #2E7D32 and incorrect answers use #B3261E.

## Screens

### State Selection
- **Route:** `state_select`
- **Purpose:** First-run and settings-accessible screen where the learner chooses one of the bundled launch-state permit prep packs.
- **Reached via:** app launch when no selected state exists; tap Change state from Settings
- **Key UI elements:** Top app bar titled PermitFair DMV Prep; List of bundled state cards loaded from content manifest; Each state card shows state name, handbook source label, lesson count, question count; Continue button enabled only after selecting a state; Small note: Content is sourced from public DMV handbooks
- **States:** loading content manifest, empty no bundled state packs found, error invalid content manifest, populated with 2-3 state packs

### Home
- **Route:** `home`
- **Purpose:** Main dashboard showing selected state, free-test eligibility, progress, and entry points into lessons and quizzes.
- **Reached via:** app launch after selected state exists; tap Back from Lesson; tap Back from Quiz Results; successful purchase on Unlock
- **Key UI elements:** Selected state header; Progress summary card with completed lessons and completed practice tests; Primary Start practice test button; Lessons list grouped by handbook section; Unlock status chip showing Free mode or Lifetime unlocked; Settings icon button
- **States:** loading selected state and progress, empty selected state has no lessons or questions, error database/content import failed, populated free user before first full test, populated free user after first full test with locked additional starts, populated lifetime unlocked user

### Lesson
- **Route:** `lesson/{lessonId}`
- **Purpose:** Shows public-handbook study material for one lesson and lets the learner start that lesson’s quiz when allowed.
- **Reached via:** tap a lesson row on Home
- **Key UI elements:** Lesson title; Handbook source section label; Scrollable lesson text; Start lesson quiz button; Completion checkmark when lesson read; Lock banner after free practice test if lifetime unlock is not owned
- **States:** loading lesson, error lesson id not found, populated unlocked lesson, populated locked lesson after free quota is consumed

### Quiz
- **Route:** `quiz/{quizMode}/{lessonId}`
- **Purpose:** Presents one question at a time, records answers, and never interrupts the first full free practice test with a paywall.
- **Reached via:** tap Start practice test on Home; tap Start lesson quiz on Lesson
- **Key UI elements:** Question number and total count; Question text; Four answer option buttons; Next button disabled until an option is selected; Progress indicator; Exit confirmation dialog; No paywall dialog inside an active free first practice test
- **States:** loading quiz questions, error not enough valid questions for requested quiz, active unanswered question, active answered question showing selected option, completed navigating to results, locked before start because free quota consumed and not purchased

### Quiz Results
- **Route:** `results/{attemptId}`
- **Purpose:** Shows score, pass/fail-style study feedback, answer review, and the first allowed unlock prompt after a complete free practice test.
- **Reached via:** Quiz completion
- **Key UI elements:** Score percentage and correct count; Review list with question, selected answer, correct answer, explanation; Retake or Continue studying button; Unlock card shown only after a completed full free practice test if not purchased; Back to Home button
- **States:** loading attempt, error attempt id not found, populated free first-test results with unlock card, populated unlocked results without paywall card

### Unlock
- **Route:** `unlock`
- **Purpose:** Explains the one-time $4.99 lifetime unlock and runs the Google Play Billing purchase flow.
- **Reached via:** tap Unlock card on Home; tap Unlock card on Quiz Results; tap Manage unlock from Settings
- **Key UI elements:** Headline: One-time lifetime unlock; Price text from Play Billing product details, expected configured price $4.99; Bullets: no weekly subscription, no trial, no account needed; Buy lifetime unlock button; Restore purchase button; Billing status/error message area
- **States:** loading product details, ready to purchase, purchase pending, purchase completed, purchase canceled, billing unavailable, error product not found

### Settings
- **Route:** `settings`
- **Purpose:** Contains state switching, purchase restore, pricing transparency, and privacy information.
- **Reached via:** tap Settings icon on Home
- **Key UI elements:** Selected state row with Change button; Purchase status row; Restore purchase button; Pricing disclosure: one-time purchase only, no subscription or trial; Privacy note: no account and no personal data collected by the app; App version text
- **States:** loading settings, populated free user, populated lifetime unlocked user, billing unavailable message

## Data model

### StatePack (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| stateId | `String` | primary key; stable id from bundled content manifest, e.g. a lowercase state code supplied by the content pack |
| displayName | `String` | state name displayed to the learner |
| handbookTitle | `String` | title of the public DMV handbook used as the source |
| handbookSourceUrl | `String` | public government handbook URL copied from the bundled content manifest |
| version | `String` | content pack version string from manifest |
| importedAtEpochMillis | `Long` | time this content pack was imported into Room |

### Lesson (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key from bundled content JSON |
| stateId | `String` | foreign key to StatePack.stateId |
| title | `String` |  |
| handbookSection | `String` | source section label from the public handbook |
| bodyMarkdown | `String` | plain Markdown subset: paragraphs and bullet lists only |
| sortOrder | `Int` | ascending display order on Home |

### Question (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key from bundled content JSON |
| stateId | `String` | foreign key to StatePack.stateId |
| lessonId | `String` | foreign key to Lesson.id |
| prompt | `String` | question text |
| explanation | `String` | short explanation sourced from handbook content |
| handbookSection | `String` | source section label used for review |
| sortOrder | `Int` | stable order before deterministic shuffling |

### AnswerOption (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key from bundled content JSON |
| questionId | `String` | foreign key to Question.id |
| text | `String` | answer option text |
| isCorrect | `Boolean` | exactly one true option per question enforced during import |
| sortOrder | `Int` | display order before deterministic per-attempt shuffle |

### UserPreference (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| key | `String` | primary key; use selected_state_id for selected state |
| value | `String` | preference value |

### LessonProgress (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| lessonId | `String` | primary key; foreign key to Lesson.id |
| readCompleted | `Boolean` | true after learner scrolls lesson and taps completion action or starts its quiz |
| lastOpenedAtEpochMillis | `Long` |  |

### QuizAttempt (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; UUID generated when quiz starts |
| stateId | `String` | foreign key to StatePack.stateId |
| lessonId | `String` | nullable for full practice tests; non-null for lesson quizzes |
| quizMode | `String` | PRACTICE_TEST or LESSON_QUIZ |
| questionIdsCsv | `String` | comma-separated ordered question ids used by this attempt |
| startedAtEpochMillis | `Long` |  |
| completedAtEpochMillis | `Long` | 0 until completed |
| scoreCorrect | `Int` | set on completion |
| scoreTotal | `Int` | question count for this attempt |
| wasFreeFullPracticeTest | `Boolean` | true only for the first completed full practice test taken without entitlement |

### QuizAnswer (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| attemptId | `String` | part of composite primary key; foreign key to QuizAttempt.id |
| questionId | `String` | part of composite primary key; foreign key to Question.id |
| selectedAnswerOptionId | `String` | foreign key to AnswerOption.id |
| answeredAtEpochMillis | `Long` |  |
| isCorrect | `Boolean` | copied at answer time for stable review |

### EntitlementCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| productId | `String` | primary key; use lifetime_unlock |
| isOwned | `Boolean` | updated from Google Play Billing queryPurchasesAsync on app start and after purchases |
| purchaseTokenHash | `String` | nullable SHA-256 hash of purchase token; never display to user |
| updatedAtEpochMillis | `Long` |  |

## Features

### Bundled public-handbook launch-state content

Imports 2-3 bundled launch-state lesson and question packs sourced from free public DMV handbooks into the local database.

- **Answers complaint:** Source content from each state's free public DMV handbook.

- **Screens:** State Selection, Home, Lesson, Quiz, Quiz Results

- **Estimated hours:** 80

**Implementation notes:** Ship app assets under assets/content/manifest.json and assets/content/{stateId}/pack.json. manifest.json contains an array of 2-3 state packs with stateId, displayName, handbookTitle, handbookSourceUrl, and version. Each pack.json contains lessons and questions. On first launch and whenever a pack version changes, ContentImporter reads the JSON with kotlinx.serialization, validates every lesson has non-empty title/bodyMarkdown, every question has exactly four AnswerOption records, exactly one correct option, non-empty explanation, and a handbookSection. If any selected state's pack fails validation, insert no partial rows for that state and expose an error state on State Selection/Home. Use Room transactions to delete and reinsert a changed state pack while preserving QuizAttempt, QuizAnswer, LessonProgress, UserPreference, and EntitlementCache rows.

**Acceptance criteria:**
- A fresh install with valid bundled assets shows 2-3 selectable state cards on State Selection.
- If a pack contains a question with zero or two correct answers, import fails for that pack and the UI shows an invalid content error instead of starting a quiz.
- Each imported question review displays its handbookSection and explanation on Quiz Results.
- No network call is made to load lessons or questions.

### First complete practice test before paywall

Lets a free learner complete one full practice test from start through results without a subscription prompt or forced unlock screen.

- **Answers complaint:** Paywall after 3-5 questions

- **Screens:** Home, Quiz, Quiz Results, Unlock

- **Estimated hours:** 18

**Implementation notes:** Create FreeAccessPolicy with states: BEFORE_FREE_TEST_COMPLETED, AFTER_FREE_TEST_COMPLETED, and LIFETIME_UNLOCKED. For a user without EntitlementCache.isOwned, Home Start practice test is enabled while no QuizAttempt exists where wasFreeFullPracticeTest=true and completedAtEpochMillis>0. Starting that test creates a PRACTICE_TEST attempt with 25 questions selected by deterministic shuffle using seed = stateId + current date epoch day + attempt UUID; if the selected state has fewer than 25 questions, use all available questions and treat that as the full test. During an active attempt with wasFreeFullPracticeTest=true, Quiz never navigates to Unlock and never displays purchase UI between questions. Only after the final answer is saved and completedAtEpochMillis is set does Quiz Results show an unlock card. After that completed attempt, any new practice test or locked lesson quiz start by a non-owner routes to Unlock before creating an attempt.

**Acceptance criteria:**
- A free first practice test containing 25 questions can advance from question 1 through question 25 without any unlock dialog or navigation to Unlock.
- The unlock card first appears on Quiz Results after the completed free test, not during the test.
- After completing the free full practice test, tapping Start practice test again routes to Unlock and does not create a new QuizAttempt.
- A lifetime unlocked user can start repeated practice tests with no free-quota restriction.

### Transparent one-time lifetime unlock

Sells a single $4.99 lifetime unlock through Google Play Billing with no trial and no recurring subscription product.

- **Answers complaint:** Price unaffordable for the actual audience

- **Screens:** Unlock, Home, Settings, Quiz Results

- **Estimated hours:** 20

**Implementation notes:** Configure exactly one Google Play in-app product id: lifetime_unlock. Do not configure any SUBS product, free trial, introductory pricing, weekly price, or account-based cancellation flow. BillingRepository uses BillingClient.startConnection, queryProductDetailsAsync for ProductType.INAPP with productId lifetime_unlock, launchBillingFlow from Unlock, PurchasesUpdatedListener to handle OK/PENDING/USER_CANCELED, and queryPurchasesAsync(ProductType.INAPP) on app start and when Restore purchase is tapped. On PURCHASED, acknowledge unacknowledged purchases with acknowledgePurchase and set EntitlementCache(productId='lifetime_unlock', isOwned=true). UI price text must display the localized ProductDetails oneTimePurchaseOfferDetails formattedPrice; the Play Console product should be priced at $4.99 as assumed by the report.

**Acceptance criteria:**
- The code contains no ProductType.SUBS query and no subscription product id.
- Unlock screen copy includes 'One-time lifetime unlock' and 'No weekly subscription. No trial.'
- A successful test purchase sets EntitlementCache.isOwned=true and unlocks additional practice tests without restarting the app.
- Restore purchase calls queryPurchasesAsync(ProductType.INAPP) and updates the purchase status row in Settings.

### No deceptive trial or cancellation surface

Avoids trial billing confusion by never offering a trial and by explaining that the app uses only a one-time Play purchase.

- **Answers complaint:** Deceptive trial billing

- **Screens:** Unlock, Settings

- **Estimated hours:** 6

**Implementation notes:** Because v1 has no subscription and no app account, Settings and Unlock must not contain trial duration text, cancellation instructions, delete-account controls, or recurring billing language. Add a static pricing disclosure component reused on Unlock and Settings: 'This app uses one optional one-time Google Play purchase. There is no subscription, no free trial, and no in-app account to cancel.' Keep the Restore purchase button for users who reinstall. If BillingClient returns ITEM_ALREADY_OWNED, immediately call queryPurchasesAsync(INAPP), update EntitlementCache, and show 'Purchase restored' rather than asking the user to buy again.

**Acceptance criteria:**
- Searching the app strings for 'trial', 'weekly', '$7.99', 'cancel subscription', and 'delete account' finds no purchase-offer copy except the required disclosure phrase 'no free trial'.
- Tapping Restore purchase when the test account owns lifetime_unlock changes the UI to lifetime unlocked.
- When BillingClient returns ITEM_ALREADY_OWNED, the UI displays a restored state and does not show an error.

### Lesson and quiz study flow

Provides handbook-based lessons, lesson quizzes, full practice tests, answer explanations, and review results.

- **Answers complaint:** baseline parity

- **Screens:** Home, Lesson, Quiz, Quiz Results

- **Estimated hours:** 32

**Implementation notes:** Home lists Lesson rows ordered by Lesson.sortOrder for the selected state. Lesson screen renders bodyMarkdown using a simple parser that supports paragraphs and bullet lines starting with '- '; no webview is used. A LESSON_QUIZ attempt uses all questions for that lesson, capped at 10 by deterministic shuffle if more than 10 exist. A PRACTICE_TEST attempt uses 25 questions across all lessons, or all available if fewer than 25. Quiz stores each selected answer immediately in QuizAnswer so process death does not lose progress; when the last question is answered, compute scoreCorrect from QuizAnswer.isCorrect, set completedAtEpochMillis, and navigate to results/{attemptId}. Results shows each question, selected answer, correct answer, explanation, and handbookSection.

**Acceptance criteria:**
- Opening a lesson displays the title, handbook section, and body text from Room.
- A lesson quiz contains only questions whose lessonId matches that lesson.
- A full practice test draws questions from more than one lesson when the state pack contains multiple lessons with questions.
- Quiz Results shows correct and incorrect answers distinctly and includes the explanation for every question.

### Local progress tracking

Tracks selected state, lesson completion, quiz attempts, scores, and free-test eligibility entirely on device.

- **Answers complaint:** baseline parity

- **Screens:** State Selection, Home, Lesson, Quiz, Quiz Results, Settings

- **Estimated hours:** 16

**Implementation notes:** Use Room DAOs returning Flow for Home progress. Mark LessonProgress.readCompleted=true when the learner reaches the bottom of the Lesson screen and taps 'Mark read' or starts that lesson's quiz. Home progress summary calculates completedLessons / totalLessons and completed practice tests where quizMode='PRACTICE_TEST' and completedAtEpochMillis>0. Free-test eligibility is derived from EntitlementCache plus existence of a completed wasFreeFullPracticeTest attempt; do not store a separate mutable free-quota flag. Switching selected state changes UserPreference selected_state_id but does not delete progress for other states.

**Acceptance criteria:**
- After completing a quiz and force-stopping/reopening the app, Home still shows the completed attempt in progress calculations.
- Changing state and then switching back preserves the first state's lesson and quiz progress.
- Deleting EntitlementCache does not falsely reset the used free practice test because eligibility is derived from QuizAttempt history.
- An unfinished attempt can be resumed or exited without being counted as the completed free full practice test.

### Polished teen-friendly UI states

Provides clear empty, loading, locked, error, and purchased states so the app feels fair rather than coercive.

- **Answers complaint:** baseline parity

- **Screens:** State Selection, Home, Lesson, Quiz, Quiz Results, Unlock, Settings

- **Estimated hours:** 28

**Implementation notes:** Implement each screen as a state holder ViewModel exposing sealed UI state classes matching the states listed in the screens section. Locked states must still explain why the user is seeing Unlock and must include a Back button. Empty content states must identify whether the selected state has no lessons, no questions, or no valid content pack. Error messages should be plain-language and recoverable where possible: invalid content returns to State Selection, billing unavailable keeps free test usable if not consumed, and missing attempt id returns to Home.

**Acceptance criteria:**
- Every screen in the navigation graph has an explicit loading state and at least one populated state in code.
- A billing unavailable state does not block the first free practice test before it has been completed.
- A missing results attempt id shows an error with a Back to Home action instead of crashing.
- A locked screen always has an affordance to return without purchasing.

## Store listing

- **Title:** PermitFair DMV Prep
- **Short description:** Finish a free DMV practice test before a fair one-time unlock.
- **Category:** Education
- **Keywords:** DMV permit test, driving permit, learner permit, practice test, driver handbook, teen driver, permit prep
- **Icon prompt:** Create a clean Android launcher icon for a DMV learner permit prep app. Use a rounded square background in warm off-white #FAF8F2 with a centered simple car glyph in deep teal #1E6B5E, plus a small checkmark badge in green to suggest passing a practice test. Flat vector style, high contrast, no text, no state seals, no official government markings.

**Long description:**

PermitFair DMV Prep helps new drivers study from public DMV handbook material without the frustrating mid-test paywall. Start with your launch-state content pack, read short handbook-based lessons, take quizzes, and complete a full practice test before any unlock prompt appears.

Why it is different:
• One complete free practice test before the paywall
• One optional $4.99 lifetime unlock
• No weekly subscription
• No free trial billing
• No account required
• Lessons and explanations based on public DMV handbook content

Designed for teen permit learners who need straightforward practice at a fair price.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://permitfair.example.com/privacy (privacy claims verified: no)
- **Data collected:** none

## Test plan

### 1. Paywall after 3-5 questions (instrumented)

1. Install a fresh debug build with valid bundled content containing at least 25 questions for the selected state.
2. Launch the app, choose the first state in State Selection, and tap Continue.
3. On Home, tap Start practice test.
4. For questions 1 through 25, select the first visible answer option and tap Next.
5. After each of questions 1 through 24, assert that the current route remains quiz/{quizMode}/{lessonId} and that no Unlock screen or billing dialog is visible.
6. After answering question 25, wait for navigation to Quiz Results.

**Expected:** The free first practice test reaches Quiz Results only after the final question, with no paywall, unlock route, or purchase prompt shown during questions 1-25.

### 2. Paywall after 3-5 questions (instrumented)

1. Complete the first free practice test in a fresh install.
2. From Quiz Results, tap Back to Home.
3. Tap Start practice test again without purchasing.

**Expected:** The app navigates to Unlock before creating another QuizAttempt, and the Unlock screen explains the one-time lifetime unlock.

### 3. Price unaffordable for the actual audience (unit)

1. Instantiate the billing product request builder used by BillingRepository.
2. Inspect the Product list passed to queryProductDetailsAsync.
3. Inspect all product ids and product types configured in the repository.

**Expected:** The only configured product is productId lifetime_unlock with ProductType.INAPP; there are zero ProductType.SUBS entries.

### 4. Deceptive trial billing (manual)

1. Open Unlock on a build connected to the Play Billing test environment.
2. Read all purchase-related copy on Unlock.
3. Open Settings and read all purchase-related copy.
4. Tap Buy lifetime unlock and complete a license tester purchase.
5. Return to Settings and tap Restore purchase.

**Expected:** The app never offers a trial or subscription, displays the localized one-time product price expected to be $4.99, unlocks after purchase, and Restore purchase shows the owned lifetime unlock.

### 5. Source content from each state's free public DMV handbook. (unit)

1. Create an in-memory Room database.
2. Run ContentImporter on a test manifest with two valid state packs and one invalid pack whose question has two correct options.
3. Collect inserted StatePack, Lesson, Question, and AnswerOption rows.

**Expected:** The two valid packs are imported with their handbookSourceUrl values, the invalid pack is rejected without partial rows, and each imported question has exactly four options with exactly one correct option.

### 6. baseline parity (instrumented)

1. Launch the app, select a state, and open the first lesson from Home.
2. Scroll to the bottom of the lesson and tap Mark read.
3. Return to Home.
4. Reopen the app process.
5. Open the same state Home screen again.

**Expected:** The lesson remains marked complete after process restart and Home progress reflects one completed lesson.

### 7. baseline parity (instrumented)

1. Start a lesson quiz from a lesson that has at least three questions.
2. Answer every question, deliberately choosing at least one incorrect answer when possible.
3. On Quiz Results, expand or view each review row.

**Expected:** Quiz Results shows the total score, selected answer, correct answer, explanation, and handbook section for every question.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release.keystore -storepass permitfair-release-pass -keypass permitfair-release-pass -alias permitfair -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=PermitFair, OU=SoloBuilder, O=PermitFair, L=Internet, S=NA, C=US"
ORG_GRADLE_PROJECT_RELEASE_STORE_FILE=$PWD/release.keystore ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD=permitfair-release-pass ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS=permitfair ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD=permitfair-release-pass ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
