# OpenStudy Companion — build spec

A study companion that keeps note-to-flashcard conversion, Smart Study questions, and practice tests free while charging only for optional export extras.

## 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:** Thea: Study Smart
- **Package id:** `study.thea.www.twa`
- **Google Play:** https://play.google.com/store/apps/details?id=study.thea.www.twa
- **appy.fyi report:** https://appy.fyi/report/study.thea.www.twa
- **Category:** Education

## Overview

- **Working name:** OpenStudy Companion (trademark cleared: no)
- **Package id:** `fyi.appy.openstudycompanion`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 5 weeks
- **Pricing:** subscription, $4 via `revenuecat`
- **Runtime AI:** yes (ai_gateway_llm): Generate flashcards from extracted notes ≈ $0.001/call; Generate Smart Study multiple-choice questions from extracted notes ≈ $0.001/call
- **Permissions:** `INTERNET`

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

- No collaboration features in v1, because collaboration would require account and sync infrastructure not needed for the core market-gap fix.
- No ads in v1, because the report specifically describes students asking to stay free rather than add ads or a more expensive tier.
- No server-side accounts, cross-device sync, or shared study libraries in v1.
- No manual-only flashcard builder as the primary workflow; manual editing is limited to reviewing generated cards and questions.
- No legal, health, financial, or child-directed claims.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel integration for Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware state collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Local Room database runtime | `androidx.room:room-runtime:2.6.1` |
| Room Kotlin coroutine extensions | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Persist first-run communication and cached subscription entitlement | `androidx.datastore:datastore-preferences:1.1.1` |
| Document metadata helpers for Android Storage Access Framework URIs | `androidx.documentfile:documentfile:1.0.1` |
| On-device OCR for imported images and rendered PDF pages | `com.google.mlkit:text-recognition:16.0.1` |
| Await ML Kit Task results from coroutines | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| AI gateway HTTP client interface | `com.squareup.retrofit2:retrofit:2.11.0` |
| Moshi JSON converter for Retrofit | `com.squareup.retrofit2:converter-moshi:2.11.0` |
| HTTP transport for AI gateway requests | `com.squareup.okhttp3:okhttp:4.12.0` |
| Kotlin JSON serialization/deserialization for AI responses | `com.squareup.moshi:moshi-kotlin:1.15.1` |
| Optional subscription entitlement and purchase flow | `com.revenuecat.purchases:purchases:8.9.0` |

## Design system

- **Primary color:** `#2563EB`
- **Background color:** `#F8FAFC`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `graduation-cap` (regular weight)
- **Theme notes:** Use Material 3 light and dark color schemes derived from primary #2563EB. Light background is #F8FAFC; dark background is #0F172A. Cards use rounded 20dp corners, 1dp #E2E8F0 borders in light mode, and 1dp #334155 borders in dark mode. Primary calls to action use filled buttons; free-core reassurance messages use tonal cards, not popups except for the first-run notice.

## Screens

### Library
- **Route:** `library`
- **Purpose:** Home screen showing saved study sets and the free-core promise.
- **Reached via:** app launch; bottom navigation Library tab; back from Study Set Detail; back from Import Notes
- **Key UI elements:** Top app bar with title OpenStudy; Prominent Import Notes button; Tonal card stating: Flashcards, Smart Study questions, and practice tests are free in v1; LazyColumn of saved study sets with title, card count, question count, and last updated date; Bottom navigation links to Library, Extras, and Settings
- **States:** loading, empty, error, populated

### Import Notes
- **Route:** `import`
- **Purpose:** Let the user paste notes or import a PDF, text file, or image for OCR/text extraction.
- **Reached via:** tap Import Notes on Library
- **Key UI elements:** Paste notes multiline text field; Pick PDF/Text/Image button using ACTION_OPEN_DOCUMENT; Selected document name and MIME type row; Extraction progress indicator with current page count for PDFs; Continue to Generate button; Error card with retry and choose different file actions
- **States:** idle, documentSelected, extracting, ocrRunning, emptyTextError, fileReadError, extracted

### Generate Study Set
- **Route:** `generate/{sourceId}`
- **Purpose:** Generate flashcards and Smart Study quiz questions from extracted notes.
- **Reached via:** Continue to Generate from Import Notes
- **Key UI elements:** Study set title text field prefilled from source document name; Flashcard count selector with 20, 40, and 60 options; Question count selector with 10, 20, and 30 options; Generate Free Study Set button; Generation progress stepper: preparing notes, generating flashcards, generating questions, saving; Preview list of generated flashcards and questions; Save Study Set button; No subscription required reassurance text
- **States:** idle, preparingText, generatingFlashcards, generatingQuestions, aiError, parseError, previewReady, saving, saved

### Study Set Detail
- **Route:** `set/{setId}`
- **Purpose:** Show one generated study set and launch flashcard practice, practice tests, or export extras.
- **Reached via:** tap a study set on Library; Save Study Set from Generate Study Set; back from practice screens
- **Key UI elements:** Study set title; Counts for flashcards, questions, and due cards; Start Flashcards button; Start Practice Test button; Export button; Tabs for Flashcards and Questions; Editable generated card/question preview rows
- **States:** loading, notFound, emptyGeneratedContent, error, populated

### Flashcard Practice
- **Route:** `practice/flashcards/{setId}`
- **Purpose:** Run a spaced-repetition flashcard review session.
- **Reached via:** tap Start Flashcards on Study Set Detail
- **Key UI elements:** Progress text such as Card 3 of 20; Front of card; Reveal Answer button; Back of card after reveal; Again, Hard, Good, Easy rating buttons; End Session button
- **States:** loading, noCards, frontShowing, backShowing, savingRating, complete, error

### Practice Test
- **Route:** `practice/test/{setId}`
- **Purpose:** Run a multiple-choice practice test using generated Smart Study questions.
- **Reached via:** tap Start Practice Test on Study Set Detail
- **Key UI elements:** Question progress indicator; Question prompt; Four answer choice buttons; Immediate correctness feedback after selection; Explanation text after selection; Next Question button; Finish Test button on final question
- **States:** loading, noQuestions, questionUnanswered, questionAnsweredCorrect, questionAnsweredIncorrect, savingAnswer, complete, error

### Attempt Results
- **Route:** `results/{attemptId}`
- **Purpose:** Show score and missed questions after a practice test or flashcard session.
- **Reached via:** Finish Test from Practice Test; complete state from Flashcard Practice
- **Key UI elements:** Score summary; Correct and incorrect counts; Missed question list with correct answers; Review Flashcards button; Back to Study Set button
- **States:** loading, notFound, populated, error

### Export
- **Route:** `export/{setId}`
- **Purpose:** Offer optional paid export formats without blocking free studying.
- **Reached via:** tap Export on Study Set Detail
- **Key UI elements:** Study set title; Export formats list: PDF summary, CSV flashcards, Anki-style TSV; Locked state card if subscription entitlement is inactive; Unlock Extras button; Share sheet button after export file is generated; Export progress indicator
- **States:** loading, locked, ready, exporting, exportReady, exportError

### Extras Subscription
- **Route:** `extras`
- **Purpose:** Explain the cheap optional subscription and make clear that the core study loop stays free.
- **Reached via:** bottom navigation Extras tab; Unlock Extras from Export locked state; Manage Extras link from Settings
- **Key UI elements:** Headline: Core studying stays free; Included free list: note import, flashcards, Smart Study questions, practice tests; Extras list: PDF summary export, CSV flashcard export, Anki-style TSV export; $4/month price text; Subscribe button; Restore Purchases button; Current entitlement status
- **States:** loadingOfferings, offeringsError, notSubscribed, purchaseInProgress, subscribed, restoreInProgress, restoreNoPurchase, purchaseError

### Settings
- **Route:** `settings`
- **Purpose:** Show app policy details, free-tier communication, and local data controls.
- **Reached via:** bottom navigation Settings tab
- **Key UI elements:** Free core policy card; Paid extras explanation card; Restore Purchases row; Delete all local study data button; Privacy Policy link placeholder; App version row
- **States:** loading, ready, deletingData, deleteError, deleteComplete

## Data model

### SourceDocument (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| displayName | `String` | file name or first 40 characters of pasted notes |
| mimeType | `String` | for pasted notes use text/plain |
| localUri | `String?` | nullable; persisted ACTION_OPEN_DOCUMENT URI string for imported files |
| extractedText | `String` | full OCR/text extraction result |
| ocrStatus | `String` | one of PENDING, RUNNING, SUCCEEDED, FAILED |
| errorMessage | `String?` | nullable extraction error shown to user |
| createdAt | `Instant` | Room TypeConverter stores epoch millis |

### StudySet (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| title | `String` |  |
| sourceDocumentId | `Long?` | nullable foreign key to SourceDocument.id |
| createdAt | `Instant` | Room TypeConverter stores epoch millis |
| updatedAt | `Instant` | Room TypeConverter stores epoch millis |

### Flashcard (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| studySetId | `Long` | foreign key to StudySet.id, cascade delete |
| front | `String` |  |
| back | `String` |  |
| sourceQuote | `String?` | nullable short quote from the notes used to justify the card |
| createdAt | `Instant` | Room TypeConverter stores epoch millis |

### QuizQuestion (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| studySetId | `Long` | foreign key to StudySet.id, cascade delete |
| prompt | `String` |  |
| choicesJson | `String` | JSON array of exactly four answer choice strings |
| correctChoiceIndex | `Int` | 0 through 3 |
| explanation | `String` |  |
| createdAt | `Instant` | Room TypeConverter stores epoch millis |

### ReviewState (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| flashcardId | `Long` | unique foreign key to Flashcard.id, cascade delete |
| intervalDays | `Int` | starts at 0 for new cards |
| ease | `Double` | starts at 2.5 |
| dueAt | `Instant` | Room TypeConverter stores epoch millis |
| lastReviewedAt | `Instant?` | nullable until first review |

### PracticeAttempt (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| studySetId | `Long` | foreign key to StudySet.id, cascade delete |
| mode | `String` | one of FLASHCARDS or TEST |
| startedAt | `Instant` | Room TypeConverter stores epoch millis |
| completedAt | `Instant?` | nullable until attempt is finished |
| score | `Int` | number correct |
| total | `Int` | number answered |

### PracticeAnswer (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| attemptId | `Long` | foreign key to PracticeAttempt.id, cascade delete |
| questionId | `Long?` | nullable; set for TEST mode answers |
| flashcardId | `Long?` | nullable; set for FLASHCARDS mode answers |
| isCorrect | `Boolean` |  |
| selectedChoiceIndex | `Int?` | nullable; 0 through 3 for TEST mode |
| answeredAt | `Instant` | Room TypeConverter stores epoch millis |

## Features

### Note, PDF, text, and image ingestion with OCR

Users can paste notes or import documents, and the app extracts text for study generation.

- **Answers complaint:** baseline parity

- **Screens:** Import Notes, Generate Study Set

- **Estimated hours:** 38

**Implementation notes:** Implement Import Notes with two input paths: a Compose multiline TextField for pasted notes and ActivityResultContracts.OpenDocument for application/pdf, text/plain, and image/* MIME types. For ACTION_OPEN_DOCUMENT results, call contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION). For text/plain, read the input stream as UTF-8. For image/*, decode the stream to Bitmap and run ML Kit TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS).process(InputImage.fromBitmap(bitmap, 0)) using kotlinx-coroutines-play-services await(). For PDFs, open ParcelFileDescriptor from the URI, create PdfRenderer, render each page into an ARGB_8888 Bitmap at 2x page dimensions, and OCR each page with ML Kit; append page text separated by two newlines. Save a SourceDocument row with ocrStatus SUCCEEDED when extractedText.trim().length >= 20; otherwise show emptyTextError and do not proceed.

**Acceptance criteria:**
- Pasting at least 20 characters enables Continue to Generate and creates a SourceDocument with mimeType text/plain.
- Importing a text/plain file saves the exact file contents into SourceDocument.extractedText.
- Importing an image containing readable printed text produces non-empty extractedText and shows the extracted state.
- Importing a PDF renders and OCRs every page in page order and stores one combined extractedText string.
- If OCR returns fewer than 20 trimmed characters, the screen shows emptyTextError and the Continue button is disabled.

### Free AI flashcard generation

The app converts extracted notes into flashcards without requiring a subscription, quota wait, or paywall.

- **Answers complaint:** Previously-free core paywalled

- **Screens:** Generate Study Set, Study Set Detail

- **Estimated hours:** 34

**Implementation notes:** Create AiGatewayService with Retrofit POST /v1/chat/completions against BuildConfig.AI_GATEWAY_BASE_URL and Authorization Bearer BuildConfig.AI_GATEWAY_API_KEY. The flashcard prompt must request JSON only: {"flashcards":[{"front":"...","back":"...","sourceQuote":"..."}]}. Split extractedText into 6000-character chunks with 500-character overlap. For each chunk, ask for enough cards to reach the selected total count across all chunks. Parse with Moshi into FlashcardResponse; if the response has leading text, recover by taking the substring from the first '{' to the last '}'. Deduplicate by lowercasing front, trimming whitespace, and removing punctuation. Save generated cards only after the user taps Save Study Set. Do not check RevenueCat entitlement, local counters, timers, or subscription status anywhere in this generation path.

**Acceptance criteria:**
- With subscription entitlement inactive, tapping Generate Free Study Set still sends the AI request and can produce flashcards.
- Generating twice in a row from the same source does not show a wait timer, cooldown message, or paywall.
- Malformed AI JSON moves the screen to parseError and preserves the user's selected source and title.
- Duplicate flashcards with the same normalized front text are saved only once.
- Saved generated flashcards appear on Study Set Detail under the Flashcards tab.

### Free AI Smart Study question generation

The app generates multiple-choice practice questions from notes without restricting them to paid users.

- **Answers complaint:** Free tier too thin to use

- **Screens:** Generate Study Set, Study Set Detail, Practice Test

- **Estimated hours:** 34

**Implementation notes:** Use the same AiGatewayService as flashcards with a separate quiz prompt requesting JSON only: {"questions":[{"prompt":"...","choices":["A","B","C","D"],"correctChoiceIndex":0,"explanation":"..."}]}. Enforce exactly four choices per question in the parser; discard any question with fewer or more than four choices or a correctChoiceIndex outside 0..3. Split long notes with the same 6000-character/500-overlap chunker and merge until the selected question count is reached. The Generate button must be labeled Generate Free Study Set and must not branch on subscription state. Save accepted questions in QuizQuestion.choicesJson as a JSON array string.

**Acceptance criteria:**
- With no active subscription, the user can select 30 questions and generate them without seeing the Extras Subscription screen.
- Every saved QuizQuestion has exactly four choices and correctChoiceIndex between 0 and 3.
- Questions with invalid choice counts from the AI response are discarded rather than crashing the save.
- Generated questions appear in Study Set Detail and can be launched in Practice Test.
- Generating a new set immediately after a previous set does not show an hour-long wait or any cooldown state.

### Practice test UI

Users can take multiple-choice tests from generated Smart Study questions and see immediate feedback.

- **Answers complaint:** baseline parity

- **Screens:** Practice Test, Attempt Results

- **Estimated hours:** 25

**Implementation notes:** When Practice Test opens, load all QuizQuestion rows for the set, shuffle them with kotlin.random.Random seeded from System.currentTimeMillis(), and create a PracticeAttempt with mode TEST. For each question, parse choicesJson with Moshi into List<String> and render four full-width buttons. After a selection, disable all choices, show green styling for the correct choice, red styling for the selected wrong choice, and display explanation. Insert a PracticeAnswer immediately after selection. On the final question, Finish Test sets PracticeAttempt.completedAt, score, and total, then navigates to results/{attemptId}.

**Acceptance criteria:**
- Opening Practice Test for a set with zero questions shows noQuestions and does not create an attempt.
- Selecting an answer records one PracticeAnswer with selectedChoiceIndex and isCorrect.
- After a choice is selected, the user cannot change that answer before tapping Next Question.
- Finishing a 10-question test creates a PracticeAttempt with total 10 and a score equal to the number of correct answers.
- Attempt Results displays the same score saved on PracticeAttempt.

### Spaced-repetition flashcard practice

Users can review generated flashcards with due-card scheduling.

- **Answers complaint:** baseline parity

- **Screens:** Flashcard Practice, Attempt Results

- **Estimated hours:** 20

**Implementation notes:** Create a ReviewState row for each Flashcard when a study set is saved, with intervalDays 0, ease 2.5, and dueAt now. Flashcard Practice loads cards where dueAt <= now, ordered by dueAt ascending; if none are due, load up to 20 newest cards as optional review so the user is not blocked. The front is shown first; Reveal Answer shows back and rating buttons. Rating algorithm: Again sets intervalDays 0, ease max(1.3, ease - 0.2), dueAt now + 10 minutes; Hard sets intervalDays max(1, current intervalDays), ease max(1.3, ease - 0.15), dueAt now + intervalDays days; Good sets intervalDays to 1 if current is 0 else round(intervalDays * ease), dueAt accordingly; Easy sets ease ease + 0.15 and intervalDays to 3 if current is 0 else round(intervalDays * ease * 1.3). Save a PracticeAnswer for each rating with isCorrect false only for Again and true for Hard/Good/Easy.

**Acceptance criteria:**
- Newly generated flashcards are due immediately.
- Tapping Reveal Answer is required before rating buttons are visible.
- Again schedules the card for 10 minutes later and decreases ease by 0.2 without going below 1.3.
- Good on a new card schedules it for 1 day later.
- Completing a flashcard session creates a PracticeAttempt with mode FLASHCARDS and total equal to rated cards.

### Visible free-core and paid-extra communication

The app clearly states that core studying is free and identifies the optional paid extras before purchase.

- **Answers complaint:** Communicate limits before shipping them

- **Screens:** Library, Extras Subscription, Settings

- **Estimated hours:** 8

**Implementation notes:** On first launch, show a dismissible Material AlertDialog with the exact copy: "Flashcards, Smart Study questions, and practice tests are free in this version. The optional $4/month extra unlocks export formats only." Store dismissal in DataStore key first_free_core_notice_seen. Also show the same promise as a persistent tonal card on Library, Extras Subscription, and Settings. Do not hide this text after purchase; subscribed users should still see what remains free.

**Acceptance criteria:**
- On a fresh install, the first screen displays the free-core notice dialog before any paywall or purchase prompt.
- After dismissing the dialog, first_free_core_notice_seen is true and the dialog does not reappear on next launch.
- Library always contains text stating that flashcards, Smart Study questions, and practice tests are free.
- Extras Subscription lists export formats as paid extras and does not list generation or practice tests as paid.
- Settings repeats the same free-core policy text.

### Cheap optional subscription for export formats

A $4/month subscription unlocks export formats while leaving the core study loop free.

- **Answers complaint:** Affordability for students

- **Screens:** Extras Subscription, Export, Study Set Detail, Settings

- **Estimated hours:** 41

**Implementation notes:** Integrate RevenueCat Purchases in Application.onCreate with the public SDK key from BuildConfig.REVENUECAT_PUBLIC_SDK_KEY. Configure one entitlement named power_exports and one monthly product shown as $4/month in copy. The Extras Subscription screen calls Purchases.sharedInstance.getOfferings(); show the current monthly package if available and a Subscribe button that calls purchaseWith(). Restore Purchases calls restorePurchases(). Cache entitlement active/inactive in DataStore key power_exports_active after every customerInfo update. Export screen checks only this entitlement: inactive shows locked state and Unlock Extras button; active allows generating three local files in cacheDir/exports: PDF-summary.txt, flashcards.csv with headers Front,Back,Source Quote, and anki.tsv with front tab back. Launch ACTION_SEND for the generated file using a FileProvider. No subscription check is used by import, generation, flashcard practice, or practice tests.

**Acceptance criteria:**
- When power_exports_active is false, Export shows locked state and Study Set Detail still allows Start Flashcards and Start Practice Test.
- When power_exports_active is true, Export can create CSV and TSV files containing all flashcards in the set.
- The Extras Subscription screen displays $4/month copy for the optional extra.
- Restore Purchases updates power_exports_active when RevenueCat customerInfo contains an active power_exports entitlement.
- Cancelling purchase leaves power_exports_active false and returns the screen to notSubscribed without affecting generated study sets.

## Store listing

- **Title:** OpenStudy Cards
- **Short description:** Free AI flashcards and practice tests from your notes.
- **Category:** Education
- **Keywords:** AI study, flashcards, practice tests, PDF to flashcards, OCR notes, spaced repetition, student study app
- **Icon prompt:** Create a clean Android app icon for an AI study companion: a blue graduation cap above two white flashcards with subtle sparkle accents, centered on a rounded square background in #2563EB, flat vector style, high contrast, no text, no brand names, suitable for Play Store launcher icon.

**Long description:**

OpenStudy Cards turns your notes, PDFs, text files, and images into flashcards and Smart Study practice questions. The core study loop is free: import notes, generate cards, create practice tests, and review with spaced repetition without a subscription gate or hour-long wait. A cheap optional $4/month extra unlocks export formats for power users, while everyday studying stays available.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://openstudycompanion.app/privacy (privacy claims verified: no)
- **Data collected:** User-provided study notes, PDF text, and OCR text sent to the AI gateway to generate flashcards and questions; Purchase and subscription entitlement status processed through RevenueCat; Locally stored study sets, flashcards, quiz questions, practice answers, and review schedule

## Test plan

### 1. Previously-free core paywalled (instrumented)

1. Install a fresh build with FakeEntitlementRepository returning power_exports_active=false.
2. Launch the app and dismiss the first-run free-core notice.
3. Tap Import Notes.
4. Paste the text: Photosynthesis converts light energy into chemical energy stored in glucose. Chlorophyll absorbs light in chloroplasts.
5. Tap Continue to Generate.
6. Select 20 flashcards and 10 questions.
7. Configure FakeAiGatewayService to return valid flashcard and question JSON.
8. Tap Generate Free Study Set.
9. Wait until previewReady, then tap Save Study Set.

**Expected:** The app saves a study set and navigates to Study Set Detail without navigating to Extras Subscription or showing any subscribe/paywall prompt.

### 2. Free tier too thin to use (instrumented)

1. Use a fresh local database and FakeEntitlementRepository returning inactive entitlement.
2. Create one SourceDocument with extractedText containing at least 500 characters.
3. Open generate/{sourceId}.
4. Generate and save one study set using FakeAiGatewayService.
5. Navigate back to generate/{sourceId} immediately without advancing the clock.
6. Generate and save a second study set using FakeAiGatewayService.

**Expected:** Both generations complete successfully, and no screen displays a cooldown, wait timer, hour-long wait message, or disabled Generate button between the two runs.

### 3. Affordability for students (manual)

1. Launch the app with RevenueCat sandbox configured and no active subscription.
2. Open the Extras tab.
3. Read the subscription copy and included/free lists.
4. Return to Library, open an existing study set, and tap Start Practice Test.
5. Return to the study set and tap Export.

**Expected:** Extras shows $4/month and lists only export formats as paid; Practice Test opens while unsubscribed; Export is locked and offers Unlock Extras.

### 4. Communicate limits before shipping them (instrumented)

1. Clear app data.
2. Launch the app.
3. Observe the first-run dialog text.
4. Tap the dialog dismiss button.
5. Open Library, Extras Subscription, and Settings.

**Expected:** The first-run dialog and all three screens state that flashcards, Smart Study questions, and practice tests are free, and that the optional $4/month extra unlocks export formats only.

### 5. baseline parity (unit)

1. Create a ReviewState with intervalDays=0, ease=2.5, and dueAt equal to a fixed Instant.
2. Call the flashcard scheduler with rating Good at that same Instant.
3. Call the flashcard scheduler with rating Again on a second ReviewState with intervalDays=5 and ease=1.4.

**Expected:** Good on the new card returns intervalDays=1 and dueAt plus one day; Again returns intervalDays=0, ease=1.3, and dueAt plus ten minutes.

### 6. baseline parity (unit)

1. Pass AI quiz JSON containing three questions to the QuizQuestionParser: one with four choices and correctChoiceIndex 2, one with three choices, and one with four choices but correctChoiceIndex 5.
2. Collect the parsed result list.

**Expected:** The parser returns exactly one QuizQuestion, with four choices and correctChoiceIndex 2.

### 7. baseline parity (instrumented)

1. Place a test PDF asset containing two pages of printed text into androidTest assets.
2. Launch Import Notes.
3. Use the test ActivityResult injection hook to provide the PDF Uri.
4. Wait for extraction to finish.
5. Open the generated SourceDocument from the test database.

**Expected:** SourceDocument.ocrStatus is SUCCEEDED and extractedText contains recognizable text from both PDF pages in page order.

## Build instructions

```sh
keytool -genkeypair -v -keystore release.keystore -storepass changeit -keypass changeit -alias openstudy -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=OpenStudy Companion, OU=Solo Builder, O=OpenStudy, L=Remote, S=NA, C=US"
export RELEASE_STORE_FILE=$PWD/release.keystore
export RELEASE_STORE_PASSWORD=changeit
export RELEASE_KEY_ALIAS=openstudy
export RELEASE_KEY_PASSWORD=changeit
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
