# FloorForm Men — build spec

A men’s pelvic floor trainer that shows the exercise library before payment and unlocks every routine with one $14.99 purchase, with no subscriptions or add-on gates.

## 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:** Kegel Men: Men's Pelvic Health
- **Package id:** `com.digitallence.kegelmanapp`
- **Google Play:** https://play.google.com/store/apps/details?id=com.digitallence.kegelmanapp
- **appy.fyi report:** https://appy.fyi/report/com.digitallence.kegelmanapp
- **Category:** Health & Fitness

## Overview

- **Working name:** FloorForm Men (trademark cleared: no)
- **Package id:** `fyi.appy.floorformmen`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 6 weeks
- **Pricing:** one-time purchase, $14.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** none

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

- No subscription plans, recurring billing, advertised free trial, or cancellation workflow in v1; the product is a single non-consumable purchase only.
- No paid AI coach, paid personalized-plan add-on, credit packs, or second unlock after purchase.
- No medical diagnosis, treatment plan, symptom triage, clinician messaging, or claims that the app cures any condition.
- No cloud sync, accounts, social/community features, or server-side progress storage.
- No large generalized fitness catalog beyond the pelvic floor exercise and breathing guidance described in the report.

## 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 compatibility utilities | `androidx.core:core-ktx:1.13.1` |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Compose UI runtime/widgets | `androidx.compose.ui:ui:1.7.4` |
| Compose Material 3 components | `androidx.compose.material3:material3:1.3.0` |
| Compose UI tooling preview/debug support | `androidx.compose.ui:ui-tooling:1.7.4` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| ViewModel integration for Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Local persistence for quiz profile, plan days, entitlement cache, sessions, and check-ins | `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` |
| Google Play one-time in-app purchase unlock | `com.android.billingclient:billing-ktx:7.1.1` |
| Bundled exercise video playback | `androidx.media3:media3-exoplayer:1.4.1` |
| PlayerView interop controls for Media3 video in Compose | `androidx.media3:media3-ui:1.4.1` |

## Design system

- **Primary color:** `#0F766E`
- **Background color:** `#FAFAF7`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `heartbeat` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light theme background is #FAFAF7, primary is #0F766E, error is #B3261E. Dark theme uses #0B4F49 for primary container and #111413 for background. Use rounded 16dp cards, 24dp screen padding, and high-contrast locked/free/purchased badges so pricing state is always visible.

## Screens

### OnboardingQuiz
- **Route:** `onboarding`
- **Purpose:** Collect the initial profile needed for the rule-based 6-week daily routine and clearly state that the app has one optional one-time unlock, not a subscription.
- **Reached via:** first app launch when no QuizProfile row exists; Settings tap "Retake setup quiz"
- **Key UI elements:** Three-step quiz cards: experience level, preferred weekly practice days, main training goal; Static health disclaimer: pelvic floor exercise guide, not medical advice; Pricing trust banner: "One purchase unlocks all 6 weeks. No subscription."; Continue button disabled until required quiz choices are selected
- **States:** empty, input_in_progress, saving, error

### DailyPlan
- **Route:** `plan`
- **Purpose:** Show today’s routine, current 6-week day number, sample/free access state, and purchased/unlocked state.
- **Reached via:** app launch after onboarding; bottom navigation Plan tab; return from completed workout
- **Key UI elements:** Today card with day index from 1 to 42; Exercise sequence list with duration, difficulty, and lock/free badge; Start routine button; Unlock callout only when the selected day contains locked exercises and entitlement is false; Progress summary row linking to Progress
- **States:** loading, empty_no_profile, free_sample_available, locked_needs_unlock, unlocked_populated, error

### ExerciseLibrary
- **Route:** `library`
- **Purpose:** Let users inspect the real exercise library before payment, including which items are free samples and which require the one-time unlock.
- **Reached via:** bottom navigation Library tab; tap "View library before buying" on Unlock; tap exercise list item from DailyPlan
- **Key UI elements:** Search field filtering title and category; Filter chips: All, Free sample, Strength, Breathing, Relaxation; Exercise cards with title, category, difficulty, duration, description preview, and free/locked/unlocked badge; Tap card to open WorkoutPlayer in preview mode for free items or Unlock for locked items
- **States:** loading, empty, populated_free_and_locked, populated_all_unlocked, error

### WorkoutPlayer
- **Route:** `workout/{planDayIndex}`
- **Purpose:** Play bundled guidance video, timer, breathing/hold cues, and step progression for a selected routine.
- **Reached via:** tap Start routine on DailyPlan; tap a free sample exercise from ExerciseLibrary; tap a completed session replay from Progress
- **Key UI elements:** Media3 video surface for current exercise; Large countdown timer; Current cue text: contract, hold, relax, breathe, rest; Previous, pause/resume, and next controls; Exercise progress indicator such as 2 of 4; Finish routine button after final exercise
- **States:** loading, ready, playing, paused, completed, locked_error, media_error_text_timer_fallback

### CheckIn
- **Route:** `checkin/{sessionId}`
- **Purpose:** Capture a short post-workout difficulty and soreness check-in used by the local adaptive routine rules.
- **Reached via:** automatic navigation after WorkoutPlayer completed state; tap "Add check-in" on Progress for a session without one
- **Key UI elements:** Perceived difficulty selector 1 to 5; Soreness/discomfort selector 0 to 4; Optional notes text field; Save check-in button; Skip button that stores the workout without adaptation input
- **States:** loading, input_in_progress, saving, saved, error

### Progress
- **Route:** `progress`
- **Purpose:** Show local workout history, streak, completion count, and recent check-ins without requiring an account.
- **Reached via:** bottom navigation Progress tab; tap progress summary row on DailyPlan
- **Key UI elements:** Current streak card; Completed sessions count; 6-week progress indicator with completed day marks; Recent sessions list with date, day index, exercises completed, and check-in ratings; Empty state explaining that progress appears after the first routine
- **States:** loading, empty, populated, error

### Unlock
- **Route:** `unlock`
- **Purpose:** Present the single transparent $14.99 full unlock and handle Google Play purchase/restore flows.
- **Reached via:** tap locked exercise or locked day; tap Unlock in Settings; tap unlock callout on DailyPlan
- **Key UI elements:** Headline: "$14.99 once. Full 6-week plan unlocked."; Bullet list: no subscription, no trial claim, no AI coach add-on, no second charge; Google Play product price loaded from BillingClient ProductDetails; Buy once button; Restore purchase button; Link to view the exercise library before buying
- **States:** loading_product, ready_not_purchased, purchase_in_progress, purchased, billing_unavailable, error

### Settings
- **Route:** `settings`
- **Purpose:** Provide billing status, restore purchase, no-subscription reassurance, privacy/reset controls, and quiz reset.
- **Reached via:** bottom navigation Settings tab
- **Key UI elements:** Purchase status row: Free sample or Fully unlocked; Restore purchase button; Static message: "This app has no subscription to cancel."; Open Google Play purchase history button; Retake setup quiz button; Delete local progress button with confirmation dialog; Privacy policy link
- **States:** loading, populated_free, populated_unlocked, restore_in_progress, delete_confirm, error

## Data model

### Exercise (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, stable slug such as quick_contractions |
| title | `String` |  |
| category | `String` | one of Strength, Breathing, Relaxation |
| difficulty | `Int` | 1 beginner, 2 intermediate, 3 advanced |
| durationSeconds | `Int` | target duration for one guided exercise item |
| instructionText | `String` | plain-language guidance shown below video and used as fallback if video playback fails |
| mediaResName | `String` | raw resource name without extension for bundled MP4 guidance clip |
| isSample | `Boolean` | true if usable before purchase |

### QuizProfile (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key; always 1 for the single local user |
| experienceLevel | `Int` | 1 beginner, 2 intermediate, 3 experienced |
| weeklyPracticeDays | `Int` | integer 3 through 7 from onboarding quiz |
| goal | `String` | selected onboarding goal label |
| createdAtEpochMillis | `Long` |  |
| updatedAtEpochMillis | `Long` |  |

### PlanDay (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| dayIndex | `Int` | 1 through 42 for the 6-week plan |
| level | `Int` | 1 beginner, 2 intermediate, 3 advanced after adaptation |
| exerciseIdsCsv | `String` | comma-separated Exercise.id values in play order |
| totalSeconds | `Int` | sum of included exercise durations |
| isSample | `Boolean` | true for the short free sample day |

### WorkoutSession (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| planDayIndex | `Int` | foreign logical reference to PlanDay.dayIndex |
| startedAtEpochMillis | `Long` |  |
| completedAtEpochMillis | `Long` | 0 when started but not completed |
| completedExerciseIdsCsv | `String` | comma-separated Exercise.id values actually completed |
| wasSample | `Boolean` | true when completed without paid entitlement |

### DailyCheckIn (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| sessionId | `Long` | foreign key to WorkoutSession.id |
| dateEpochDay | `Long` | local date converted to epoch day |
| perceivedDifficulty | `Int` | 1 easy through 5 too hard |
| soreness | `Int` | 0 none through 4 severe discomfort |
| notes | `String` | empty string when user leaves notes blank |

### PurchaseEntitlement (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| productId | `String` | primary key; full_unlock |
| unlocked | `Boolean` | true only after BillingClient reports PURCHASED and acknowledged |
| source | `String` | purchase, restore, or debug_fake_billing |
| updatedAtEpochMillis | `Long` |  |

## Features

### Visible library and free sample before payment

Users can inspect the exercise library and complete a short sample routine before buying.

- **Answers complaint:** No real free trial

- **Screens:** DailyPlan, ExerciseLibrary, WorkoutPlayer, Unlock

- **Estimated hours:** 20

**Implementation notes:** On first launch seed Room with 10 Exercise rows from a hard-coded ExerciseSeed list: quick_contractions, slow_holds, elevator_holds, endurance_hold, reverse_relaxation_breathing, diaphragmatic_breathing, sitting_contractions, standing_contractions, bridge_pelvic_floor_cue, cool_down_relaxation. Mark quick_contractions, diaphragmatic_breathing, and cool_down_relaxation as isSample=true. Generate PlanDay dayIndex=0 as the free sample using those three sample exercise IDs and route it from DailyPlan when PurchaseEntitlement.unlocked=false. ExerciseLibrary must show all 10 rows before purchase with badges; tapping a non-sample locked exercise navigates to Unlock instead of showing a blocking pop-up. The app must not use the phrase "free trial" anywhere; it uses "free sample" so there is no immediate-charge trial promise.

**Acceptance criteria:**
- A fresh install with no purchase can open ExerciseLibrary and see all 10 exercise titles.
- A fresh install can start and complete the sample routine without opening BillingClient.launchBillingFlow.
- A fresh install tapping a locked non-sample exercise navigates to Unlock and shows the one-time price message, not an unavoidable modal on app launch.
- No visible string in the app contains "free trial".

### Single transparent full unlock

A single $14.99 non-consumable purchase unlocks all routines, all exercises, and the personalized plan with no second charge.

- **Answers complaint:** Paying once doesn't unlock the app

- **Screens:** Unlock, Settings, DailyPlan, ExerciseLibrary, WorkoutPlayer

- **Estimated hours:** 40

**Implementation notes:** Create a BillingRepository around BillingClient 7.1.1. Query exactly one ProductDetails item with productId="full_unlock" and ProductType.INAPP. Do not define, query, or launch any ProductType.SUBS product and do not define any add-on product IDs. On PurchasesUpdatedListener receiving Purchase.PurchaseState.PURCHASED for full_unlock, call acknowledgePurchase if not acknowledged, then upsert PurchaseEntitlement(productId="full_unlock", unlocked=true, source="purchase"). On app start and Settings restore, call queryPurchasesAsync for ProductType.INAPP and upsert the same entitlement when full_unlock is present. Gate content only by this one boolean: if unlocked=true, all PlanDay rows 1 through 42 and all Exercise rows are playable. The Unlock screen text must state "$14.99 once" and "No AI coach add-on. No second charge."

**Acceptance criteria:**
- After a fake or license-test purchase of full_unlock, every exercise card shows unlocked and every plan day can be started.
- The codebase contains no ProductType.SUBS query or subscription product ID.
- The app never displays a second paid offer after PurchaseEntitlement.unlocked becomes true.
- Restore purchase on a reinstall sets PurchaseEntitlement.unlocked=true when Google Play returns full_unlock as purchased.

### No subscription or cancellation trap

The app avoids the cancellation failure mode by offering no recurring billing and by showing the current non-subscription purchase status in Settings.

- **Answers complaint:** Cancellation doesn't work

- **Screens:** Settings, Unlock

- **Estimated hours:** 10

**Implementation notes:** Settings reads PurchaseEntitlement and displays either "Free sample active" or "Fully unlocked". Directly below it always show "This app has no subscription to cancel." Add an "Open Google Play purchase history" button that launches Intent.ACTION_VIEW with Uri.parse("https://play.google.com/store/account/orderhistory") so users can verify/manage the one-time purchase in Google Play. Because the pricing model is INAPP only, there is no in-app cancel API call and no subscription state machine. Unit-test BillingRepository configuration so only full_unlock/INAPP is queryable.

**Acceptance criteria:**
- Settings always includes the exact text "This app has no subscription to cancel."
- The only billing product configured by the app is full_unlock with ProductType.INAPP.
- Tapping Open Google Play purchase history sends an ACTION_VIEW intent to https://play.google.com/store/account/orderhistory.
- There is no screen offering a monthly, weekly, annual, or trial subscription.

### Rule-based personalized 6-week routine

The onboarding quiz creates a local 42-day routine and adapts future difficulty from check-ins without any paid personalized-plan add-on.

- **Answers complaint:** I subscribed to the 1-month plan, but after just 2 days the app started asking for an additional $20 for a "personalized plan."

- **Screens:** OnboardingQuiz, DailyPlan, CheckIn, Progress

- **Estimated hours:** 50

**Implementation notes:** After OnboardingQuiz saves QuizProfile, generate PlanDay rows dayIndex 1 through 42. Use experienceLevel as the starting level. For level 1 days, choose 3 exercises: one Strength difficulty 1, one Breathing, one Relaxation, total target 6 to 8 minutes. For level 2, choose 4 exercises with Strength difficulty up to 2, total target 9 to 12 minutes. For level 3, choose 5 exercises with Strength difficulty up to 3, total target 12 to 15 minutes. Rotate through Exercise ids deterministically by dayIndex modulo the eligible list size so the same profile always gets the same plan. After each DailyCheckIn, recompute only future PlanDay rows: if the average of the last 3 perceivedDifficulty values is <=2 and average soreness is <=1, increase level by 1 up to 3; if latest perceivedDifficulty >=4 or soreness >=3, decrease level by 1 down to 1 for the next 3 days. This engine is part of the full_unlock entitlement and must never navigate to a separate upsell.

**Acceptance criteria:**
- Completing onboarding creates exactly 42 paid PlanDay rows plus the separate sample day.
- Changing check-in ratings changes future PlanDay.level values according to the specified rules without creating any billing prompt.
- For an unlocked user, day 3 and later personalized plan days are playable without any additional purchase screen.
- Deleting local progress removes sessions/check-ins but regenerates the 42-day plan from the saved or newly entered quiz profile.

### Guided workout player with video, timer, and cues

Each routine plays bundled exercise guidance with clear timer cues for contractions, holds, relaxation, and breathing.

- **Answers complaint:** Thin content once inside

- **Screens:** WorkoutPlayer, DailyPlan, ExerciseLibrary

- **Estimated hours:** 55

**Implementation notes:** Bundle one MP4 per Exercise in app/src/main/res/raw using each Exercise.mediaResName. WorkoutPlayer loads the current Exercise, creates an ExoPlayer via Media3, and sets a RawResourceDataSource URI for the MP4. The timer runs from Exercise.durationSeconds using a coroutine ticker at 1-second intervals. For Strength exercises, cycle cue text through 5 seconds "contract", configured hold seconds, 5 seconds "relax/rest" until duration ends; for Breathing and Relaxation exercises, alternate 4 seconds "inhale" and 6 seconds "exhale/relax". Pause must pause both ExoPlayer and countdown; resume restarts both from the saved remaining seconds. If Media3 reports playback error, keep the timer usable and show Exercise.instructionText in a prominent card with state media_error_text_timer_fallback.

**Acceptance criteria:**
- Starting a routine displays video, countdown, cue text, and exercise index before the timer begins.
- Pause freezes the remaining seconds for at least 3 seconds of wall-clock time; resume continues from the same value or one second lower.
- A forced media error still allows completing the exercise using text instructions and timer.
- Completing the final exercise inserts a WorkoutSession with completedAtEpochMillis greater than startedAtEpochMillis.

### Local progress tracking and post-workout check-ins

Completed routines, streak, 6-week progress, and check-in ratings are stored locally and shown without an account.

- **Answers complaint:** baseline parity

- **Screens:** WorkoutPlayer, CheckIn, Progress, DailyPlan

- **Estimated hours:** 35

**Implementation notes:** When WorkoutPlayer completes, insert WorkoutSession with planDayIndex, startedAtEpochMillis, completedAtEpochMillis, completedExerciseIdsCsv, and wasSample. Navigate to CheckIn with the new sessionId. CheckIn inserts DailyCheckIn unless skipped. Progress computes current streak by grouping completed sessions by local date epoch day and counting consecutive days backward from today; completed day marks are all distinct planDayIndex values with completedAtEpochMillis>0. Use Compose Canvas or simple Rows/Boxes for the 42-day progress indicator; do not add a chart library. All data remains in Room; there is no login or sync.

**Acceptance criteria:**
- After one completed routine, Progress populated state shows completed session count 1.
- Saving a check-in with difficulty 4 and soreness 3 stores those exact Int values in DailyCheckIn for the sessionId.
- The streak count increments only for consecutive local calendar days with at least one completed session.
- The app can be force-closed and reopened and still shows previous sessions from Room.

### Trust-first purchase presentation

All payment surfaces explain what is free, what is locked, and that the one-time purchase fully unlocks the app without blocking app launch.

- **Answers complaint:** A pop-up keeps appearing and does not go away unless you pay. after downloading the app when you open it it immediately takes you to a page for another purchase. there's no way to avoid it. it is forcing you to make that purchase.

- **Screens:** OnboardingQuiz, DailyPlan, ExerciseLibrary, Unlock, Settings

- **Estimated hours:** 30

**Implementation notes:** App start destination must be OnboardingQuiz for new users or DailyPlan for returning users, never Unlock. Locked content uses inline cards and explicit navigation to Unlock only after the user taps a locked item or unlock callout. Do not implement any full-screen paywall dialog, timed pop-up, repeated modal, or back-button trap. Unlock must include a "View library before buying" text button that navigates to ExerciseLibrary. DailyPlan and ExerciseLibrary must remain accessible via bottom navigation when entitlement is false.

**Acceptance criteria:**
- On a fresh install, the first visible screen is OnboardingQuiz, not Unlock.
- Pressing Android back from Unlock returns to the previous screen and does not reopen Unlock automatically.
- A user with no purchase can use bottom navigation to reach DailyPlan, ExerciseLibrary, Progress, and Settings.
- No composable in the app shows a non-dismissible purchase modal.

## Store listing

- **Title:** FloorForm Men
- **Short description:** Pelvic floor routines with one honest unlock. No subscription.
- **Category:** Health & Fitness
- **Keywords:** pelvic floor, kegel men, men health, exercise timer, breathing exercises, one time purchase, no subscription, fitness routine
- **Icon prompt:** Create a clean Android launcher icon for an app named FloorForm Men: rounded square background in deep teal #0F766E, centered simple white line glyph combining a heartbeat pulse with a subtle human core/pelvis curve, flat vector style, high contrast, no text, no medical cross, no gradients, optimized for small sizes.

**Long description:**

FloorForm Men is a straightforward pelvic floor exercise app built around trust: view the library first, try a free sample routine, then unlock the full 6-week plan with one $14.99 purchase.

What you get:
• Guided pelvic floor and breathing routines
• A 6-week daily plan generated from a short setup quiz
• Clear timers, hold/relax cues, and bundled exercise guidance
• Local progress tracking and post-workout check-ins
• One full unlock purchase — no subscription, no trial trap, no AI coach add-on, and no second charge

This app is an exercise guide, not medical advice. If you have pain, new symptoms, or a medical condition, consult a qualified clinician before starting exercise.

## Legal

- **Regulated category:** health
- **Privacy policy URL:** https://floorform.example.com/privacy (privacy claims verified: no)
- **Data collected:** Pelvic floor setup quiz answers stored locally on device; Workout completion history stored locally on device; Post-workout difficulty and soreness check-in ratings stored locally on device; Google Play one-time purchase entitlement status cached locally on device

## Test plan

### 1. A pop-up keeps appearing and does not go away unless you pay; app immediately takes you to another purchase page (instrumented)

1. Install a fresh debug build with an empty Room database and FakeBillingRepository set to not purchased.
2. Launch MainActivity.
3. Record the current navigation route.
4. Tap through OnboardingQuiz with experienceLevel=1, weeklyPracticeDays=5, goal="general strength".
5. Verify bottom navigation is visible.
6. Tap Library, then Plan, then Settings without purchasing.

**Expected:** Initial route is onboarding, Unlock is not shown automatically, bottom navigation remains usable without purchase, and no non-dismissible purchase modal appears.

### 2. No real free trial (instrumented)

1. Install a fresh debug build with FakeBillingRepository set to not purchased.
2. Complete onboarding.
3. Navigate to ExerciseLibrary.
4. Assert that 10 exercise cards are displayed.
5. Tap the card with id quick_contractions or title Quick Contractions.
6. Tap Start sample routine and let the timer complete using test clock advancement.

**Expected:** The sample routine completes and creates one WorkoutSession with wasSample=true without invoking BillingClient.launchBillingFlow.

### 3. Paying once doesn't unlock the app (unit)

1. Instantiate BillingRepository with fake purchase source returning one PURCHASED and acknowledged product full_unlock of type INAPP.
2. Call refreshEntitlements().
3. Read PurchaseEntitlement from the in-memory Room database.
4. Call ContentGate.canPlay for every Exercise id and for PlanDay dayIndex 1 through 42.

**Expected:** PurchaseEntitlement.unlocked is true and ContentGate.canPlay returns true for every exercise and every paid plan day with no other product required.

### 4. Cancellation doesn't work (unit)

1. Instantiate BillingProductConfig.
2. Read all configured product definitions.
3. Filter product definitions where productType equals ProductType.SUBS.
4. Read the unlock product definition.

**Expected:** The subscription product list is empty and the only configured product is productId full_unlock with ProductType.INAPP.

### 5. Additional $20 personalized plan after 2 days (unit)

1. Create QuizProfile id=1 with experienceLevel=1 and generate the 42-day plan.
2. Set PurchaseEntitlement.unlocked=true.
3. Insert completed sessions and check-ins for dayIndex 1 and 2.
4. Ask ContentGate.canPlay for PlanDay dayIndex=3.
5. Run RoutineEngine.recomputeFuturePlan after a difficult check-in perceivedDifficulty=4, soreness=3.

**Expected:** Day 3 remains playable with the existing full_unlock entitlement, no billing event is emitted, and future PlanDay.level values decrease according to the rule-based adaptation.

### 6. Thin content once inside (instrumented)

1. Install a debug build with FakeBillingRepository set to purchased.
2. Complete onboarding.
3. Navigate to ExerciseLibrary.
4. Count visible exercise cards.
5. Open a paid exercise from the library.
6. On WorkoutPlayer, wait until ready state and read visible cue text and countdown.
7. Inject a Media3 playback error through the debug player wrapper.

**Expected:** At least 10 exercise cards are visible; WorkoutPlayer shows video area, countdown, and cue text; after the injected media error it shows text instructions and the timer remains completable.

### 7. baseline parity (instrumented)

1. Install a debug build with FakeBillingRepository set to purchased.
2. Complete onboarding.
3. Start day 1 from DailyPlan.
4. Advance the workout timer until completion.
5. On CheckIn, select perceivedDifficulty=4 and soreness=3, then tap Save.
6. Navigate to Progress.

**Expected:** Progress shows completed session count 1, the day 1 mark is completed, and the stored DailyCheckIn for the session has perceivedDifficulty=4 and soreness=3.

### 8. Paying once doesn't unlock the app (manual)

1. Upload an internal testing build configured with the Google Play product full_unlock as a one-time in-app product priced at $14.99.
2. Install from Play as a license tester account with no previous purchase.
3. Open the app, complete onboarding, and tap a locked exercise.
4. Buy full_unlock using the Play test purchase dialog.
5. After purchase success, close the app from recents and reopen it.
6. Navigate to Library and DailyPlan.

**Expected:** All exercises and all 42 plan days remain unlocked after reopening, and no second purchase, AI coach, or personalized-plan add-on is offered.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
export RELEASE_STORE_FILE="$PWD/release.keystore"
export RELEASE_STORE_PASSWORD="changeit-floorform-release"
export RELEASE_KEY_ALIAS="floorform_release"
export RELEASE_KEY_PASSWORD="changeit-floorform-key"
if [ ! -f "$RELEASE_STORE_FILE" ]; then keytool -genkeypair -v -keystore "$RELEASE_STORE_FILE" -storepass "$RELEASE_STORE_PASSWORD" -alias "$RELEASE_KEY_ALIAS" -keypass "$RELEASE_KEY_PASSWORD" -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=FloorForm Men,O=Solo Builder,C=US"; fi
./gradlew bundleRelease
```

## Human gates still required

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