# ClefTrust Reader — build spec

A sheet-music reader that makes every subscription charge explicit, restores Pro access reliably, and gives users a real billing-support email path instead of a chatbot.

## 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:** MuseScore: Music Chords & Tabs
- **Package id:** `com.musescore.playerlite`
- **Google Play:** https://play.google.com/store/apps/details?id=com.musescore.playerlite
- **appy.fyi report:** https://appy.fyi/report/com.musescore.playerlite
- **Category:** Music & Audio

## Overview

- **Working name:** ClefTrust Reader (trademark cleared: no)
- **Package id:** `fyi.appy.cleftrustreader`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 10 weeks
- **Pricing:** subscription, $4.99 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `INTERNET`

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

- No licensed copyrighted commercial-score marketplace in v1; the catalog is limited to public-domain scores and user-imported MusicXML/MIDI files.
- No AI optical-music-recognition ingestion in v1; users import already-digital MusicXML or MIDI files rather than photos/PDFs.
- No social network, score sharing feed, comments, followers, or creator monetization in v1.
- No custom account system in v1; subscription access follows the user through Google Play/RevenueCat restore, not an app-specific login.
- No silent renewal management inside the app; cancellation and renewal are handled by Google Play subscription management, linked from the app.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose activity integration and app entry point | `androidx.activity:activity-compose:1.9.3` |
| Compose UI dependency version alignment | `androidx.compose:compose-bom:2024.10.01` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel support 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 catalog, imported-score metadata, subscription-cache, and billing-audit persistence | `androidx.room:room-runtime:2.6.1` |
| Coroutine extensions for Room queries and transactions | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor used through KSP | `androidx.room:room-compiler:2.6.1` |
| Kotlin coroutines on Android for repository, parser, and playback state flows | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Parsing bundled catalog JSON and storing billing-event snapshots | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Subscription purchase, entitlement lookup, and restore-purchases flow | `com.revenuecat.purchases:purchases:8.10.4` |
| Unit-test coroutine dispatcher control | `org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0` |
| AndroidX JUnit runner and rules for instrumented Compose tests | `androidx.test.ext:junit:1.2.1` |
| Compose UI testing APIs | `androidx.compose.ui:ui-test-junit4:1.7.4` |

## Design system

- **Primary color:** `#1B6E5C`
- **Background color:** `#FFFDF7`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `music-note` (regular weight)
- **Theme notes:** Use Material 3 dynamic color only when Android 12+ dynamic color is available; otherwise use #1B6E5C primary, #FFFDF7 light background, #121212 dark background, and #B3261E error. Score pages render on off-white #FFFDF7 in light mode and #1A1A1A in dark mode, with staff lines #222222 light-mode and #E6E1E5 dark-mode.

## Screens

### Library
- **Route:** `library`
- **Purpose:** App launch destination showing imported scores, recently opened catalog scores, current Pro status, and primary navigation to catalog, import, purchase, and support.
- **Reached via:** app launch; bottom navigation Library tab; system back from Catalog; system back from ScoreReader; successful import from ImportScore
- **Key UI elements:** Top app bar with title ClefTrust Reader; Pro status chip showing Free, Pro active, or Restore needed; Search field filtering local imported/recent scores by title or composer; LazyColumn of score rows with title, composer, source label, and content type; Import score button; Browse public-domain catalog button; Manage Pro button; Billing help button
- **States:** loading, empty, error, populated, subscription_checking, subscription_error

### Catalog
- **Route:** `catalog`
- **Purpose:** Search and open bundled public-domain scores without triggering any paid per-score purchase flow.
- **Reached via:** tap Browse public-domain catalog on Library; bottom navigation Catalog tab
- **Key UI elements:** Search text field with placeholder Search public-domain scores; Composer filter chips generated from catalog metadata; LazyColumn of public-domain score rows; Public-domain notice text; Open score action on each row
- **States:** loading, empty, error, populated, no_search_results

### ImportScore
- **Route:** `import`
- **Purpose:** Let the user add local MusicXML or MIDI files through Android's document picker and validate them before adding to Library.
- **Reached via:** tap Import score on Library; bottom navigation Import tab
- **Key UI elements:** Accepted formats card listing .musicxml, .xml, .mxl, and .mid; Pick file button using ACTION_OPEN_DOCUMENT; Validation progress indicator; Parsed score preview with title, composer, parts, and measure count; Add to Library button; Validation error panel with exact unsupported-format reason
- **States:** idle, file_picker_open, validating, valid_preview, unsupported_file_error, parse_error, saved

### ScoreReader
- **Route:** `score/{scoreId}`
- **Purpose:** Render a MusicXML/MIDI-derived score, provide tabs/chords view, and control practice playback.
- **Reached via:** tap a score row on Library; tap a score row on Catalog; tap Open after successful import on ImportScore
- **Key UI elements:** Scrollable Compose Canvas staff renderer; Title and composer header; Playback toolbar with play, pause, stop, tempo minus, tempo plus, and current tempo; Tabs/Chords toggle; Current measure highlight; Pro-required banner for practice tools when entitlement is inactive; Open Pro screen button inside Pro-required banner
- **States:** loading, render_error, populated_score, playback_ready, playing, paused, pro_required

### ProPurchase
- **Route:** `pro`
- **Purpose:** Show the $4.99/month subscription honestly, require an explicit in-app confirmation before launching Google Play purchase, and offer restore.
- **Reached via:** tap Manage Pro on Library; tap Open Pro screen in ScoreReader pro-required banner; tap Restore purchases on Library Pro status chip
- **Key UI elements:** Price card reading Pro practice tools, $4.99/month; Plain-language renewal text stating Google Play manages renewal and cancellation; Subscribe button; Explicit confirmation dialog requiring checkbox I understand this is a recurring $4.99/month subscription; Restore purchases button; Open Google Play subscription management link; Entitlement status message
- **States:** loading_offering, offering_error, not_subscribed, confirming, purchase_in_progress, purchase_cancelled, purchase_error, subscribed, restore_in_progress, restore_success, restore_no_purchase_found, restore_error

### BillingHelp
- **Route:** `billing-help`
- **Purpose:** Provide a visible human escalation path for refund and billing disputes through an email intent.
- **Reached via:** tap Billing help on Library; tap Need help with billing on ProPurchase
- **Key UI elements:** Explanation that billing disputes are handled by human email, not chatbot; Email support button addressed to support@cleftrust.app; Copy support email button; Include diagnostic checkbox; Diagnostic preview showing app version, RevenueCat app user ID, entitlement status, and last billing event timestamp
- **States:** ready, email_client_missing, copied_email, diagnostics_loading, diagnostics_error

## Data model

### ScoreEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable UUID for imported scores or catalog slug for bundled scores |
| title | `String` | display title parsed from MusicXML movement-title/work-title or catalog metadata |
| composer | `String` | empty string when unknown |
| instrumentTagsJson | `String` | JSON array of lowercase instrument tags used for filtering |
| sourceLabel | `String` | for example Public domain catalog or Imported file |
| sourceUrl | `String?` | nullable public-domain source URL for catalog scores |
| localAssetPath | `String?` | nullable path under assets/catalog for bundled MusicXML |
| persistedUri | `String?` | nullable ACTION_OPEN_DOCUMENT URI string for imported files; persistable read permission is taken |
| contentType | `String` | one of musicxml, mxl, midi |
| isCatalog | `Boolean` | true for bundled public-domain catalog entries |
| createdAt | `Instant` | stored through Room TypeConverter as epoch milliseconds |
| lastOpenedAt | `Instant?` | nullable; stored through Room TypeConverter as epoch milliseconds |

### SubscriptionCacheEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; always the literal value current |
| revenueCatAppUserId | `String` | RevenueCat app user ID used in restore diagnostics |
| isProActive | `Boolean` | true when RevenueCat CustomerInfo entitlements.active contains pro |
| latestExpirationAt | `Instant?` | nullable; subscription expiration parsed from RevenueCat entitlement |
| lastCheckedAt | `Instant` | stored through Room TypeConverter as epoch milliseconds |
| lastErrorMessage | `String?` | nullable; most recent restore or entitlement lookup error shown to user |

### BillingEventEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| eventType | `String` | one of explicit_confirm_shown, explicit_confirm_checked, purchase_started, purchase_cancelled, purchase_failed, purchase_succeeded, restore_started, restore_succeeded, restore_no_purchase, restore_failed |
| productId | `String?` | nullable RevenueCat/Google Play product identifier |
| displayPrice | `String?` | nullable localized price string shown to the user, for example $4.99/month |
| createdAt | `Instant` | stored through Room TypeConverter as epoch milliseconds |
| detailsJson | `String` | small JSON object with non-sensitive purchase/restore result metadata |

## Features

### MusicXML and MIDI score import with local rendering

Users can import MusicXML, compressed MusicXML, or MIDI files and view them as readable sheet music.

- **Answers complaint:** baseline parity

- **Screens:** ImportScore, Library, ScoreReader

- **Estimated hours:** 120

**Implementation notes:** Use ACTION_OPEN_DOCUMENT with MIME types application/vnd.recordare.musicxml+xml, application/xml, text/xml, audio/midi, audio/x-midi, and */* as a fallback, then call ContentResolver.takePersistableUriPermission(uri, FLAG_GRANT_READ_URI_PERMISSION). Validate by file extension and header: .mxl is a ZIP and must contain META-INF/container.xml pointing to the root MusicXML file; .musicxml/.xml must parse with XmlPullParser; .mid must start with MThd. Parse MusicXML part-list, part, measure, attributes/divisions, key/fifths, time/beats, time/beat-type, clef/sign, note/rest, note/pitch/step, alter, octave, duration, type, dot, chord, tie, accidental, lyric/text. For MIDI, implement a minimal Standard MIDI File parser for format 0 and 1: read header length, division ticks per quarter, track chunks, variable-length delta times, note-on, note-off, tempo meta event 0xFF 0x51, time-signature meta event 0xFF 0x58, and track-name meta event 0xFF 0x03; convert note numbers to pitch names and quantize durations to nearest sixteenth note. Store imported metadata in ScoreEntity. Render in ScoreReader using a Compose Canvas: draw five staff lines per system, measure bars, treble/bass clef text glyph fallback, noteheads as filled ovals, stems, rests as simple symbols, and ledger lines when pitch is outside the staff. If parsing fails, show the parser exception message in ImportScore parse_error and do not insert a ScoreEntity.

**Acceptance criteria:**
- Selecting a valid .musicxml file shows a preview with non-empty title or file name, part count, and measure count before saving.
- After tapping Add to Library, the imported score appears in Library without restarting the app.
- Opening the imported score draws at least one staff system, measure bars, and noteheads on ScoreReader.
- Selecting a file whose first bytes are not XML, ZIP, or MThd shows unsupported_file_error and does not create a library row.
- A .mxl file whose META-INF/container.xml has no rootfile entry shows parse_error with text containing container.xml.

### Public-domain catalog and search

Users can browse and search bundled public-domain scores without a paid per-score checkout.

- **Answers complaint:** baseline parity

- **Screens:** Catalog, Library, ScoreReader

- **Estimated hours:** 80

**Implementation notes:** Ship a bundled asset app/src/main/assets/catalog/catalog.json containing an array of public-domain score metadata with fields id, title, composer, instrumentTags, sourceUrl, and localAssetPath. On first launch, read this JSON with kotlinx.serialization, insert rows into ScoreEntity with isCatalog=true inside a Room transaction, and skip rows whose id already exists. Search uses a Room query with LOWER(title) LIKE '%' || :query || '%' OR LOWER(composer) LIKE '%' || :query || '%' OR LOWER(instrumentTagsJson) LIKE '%' || :query || '%', ordered by composer then title. Catalog rows open directly in ScoreReader by scoreId; there is no buy button, price label, or one-time score checkout in Catalog.

**Acceptance criteria:**
- On a fresh install, Catalog leaves loading state after the seed import transaction completes.
- Typing a composer substring filters Catalog rows case-insensitively.
- Typing a query with no matches shows no_search_results rather than an empty blank screen.
- Tapping a catalog row navigates to score/{scoreId} and does not open ProPurchase.
- Catalog screen contains visible text stating the listed scores are public-domain sources.

### Practice playback with tabs/chords view

Users can play a score back at adjustable tempo and switch to a simplified tabs/chords practice overlay.

- **Answers complaint:** baseline parity

- **Screens:** ScoreReader, ProPurchase

- **Estimated hours:** 80

**Implementation notes:** Build a ScorePlaybackEngine that consumes the parsed score event list produced for rendering. Represent each event as pitchMidiNumber:Int?, startTick:Long, durationTick:Long, measureIndex:Int, chordName:String?. Tempo defaults to the MusicXML/MIDI tempo if present, otherwise 100 BPM. For audible playback in v1, use android.media.ToneGenerator on STREAM_MUSIC: map each note-on event to a short tone duration equal to min(durationMs, 1000) and schedule events with a CoroutineScope using delay based on ticksPerQuarter and current BPM; rests advance time without sound. Tempo buttons clamp BPM between 40 and 220 in 5-BPM increments. The current measure highlight is derived from the latest event whose startTick is less than or equal to playbackTick. Tabs/Chords view computes chord labels per measure by collecting simultaneous pitch classes on the same startTick and matching major/minor triads; if no triad matches, display pitch names separated by hyphens. Practice tools are Pro-gated: when SubscriptionCacheEntity.isProActive is false, show score rendering but disable play and tabs/chords controls behind a Pro-required banner.

**Acceptance criteria:**
- Tapping Play changes ScoreReader state to playing and advances the highlighted measure at least once for a score with multiple measures.
- Tapping Pause stops measure advancement and changes state to paused.
- Tempo plus increases the displayed BPM by exactly 5 until the displayed value reaches 220.
- Tempo minus decreases the displayed BPM by exactly 5 until the displayed value reaches 40.
- Toggling Tabs/Chords shows at least one chord label for a measure containing a recognized major or minor triad.
- When isProActive is false, playback controls are disabled and the Pro-required banner is visible.

### Explicit-confirmation subscription purchase

The app sells Pro as a clearly labeled $4.99/month subscription and never starts purchase flow without a separate in-app confirmation.

- **Answers complaint:** Billing converts or renews without clear consent

- **Screens:** ProPurchase, Catalog, ScoreReader

- **Estimated hours:** 60

**Implementation notes:** Configure RevenueCat with entitlement id pro, offering id default, and one monthly package representing the $4.99/month product. ProPurchase first calls Purchases.sharedInstance.getOfferings and displays the package title plus localized price, but the static explanatory copy must always include the report-supported price string $4.99/month. Tapping Subscribe does not call purchase immediately; it opens an AlertDialog that says: This starts a recurring $4.99/month subscription managed by Google Play. It is not a one-time song purchase. The dialog contains an unchecked checkbox labeled I understand this is a recurring $4.99/month subscription. Only after the checkbox is checked and the user taps Continue to Google Play should code insert BillingEvent explicit_confirm_checked and call RevenueCat purchase for the monthly package. Record purchase_started before launching purchase and record purchase_succeeded, purchase_cancelled, or purchase_failed from the callback. The app must not contain any code path that starts RevenueCat purchase from Catalog or ScoreReader without going through this dialog.

**Acceptance criteria:**
- Tapping Subscribe while the confirmation checkbox is unchecked cannot launch the Google Play purchase sheet.
- The confirmation dialog includes the exact phrase recurring $4.99/month subscription.
- A BillingEvent with eventType explicit_confirm_checked is written before any purchase_started event.
- Opening a public-domain catalog score never creates a purchase_started BillingEvent.
- If RevenueCat returns userCancelled=true, ProPurchase shows purchase_cancelled and isProActive remains false.

### Reliable restore-purchases and entitlement cache

The app checks and restores Pro access from RevenueCat so paid users are not gated after reinstalling or moving devices.

- **Answers complaint:** Paid content still gated or lost

- **Screens:** Library, ProPurchase, ScoreReader

- **Estimated hours:** 40

**Implementation notes:** At app launch and whenever ProPurchase opens, call Purchases.sharedInstance.getCustomerInfo. Treat Pro as active only when customerInfo.entitlements["pro"]?.isActive == true. Save SubscriptionCacheEntity with revenueCatAppUserId, isProActive, latestExpirationAt, lastCheckedAt, and lastErrorMessage. The Restore purchases button calls Purchases.sharedInstance.restorePurchases and then applies the same entitlement check. If restore succeeds but pro is inactive, insert BillingEvent restore_no_purchase and show restore_no_purchase_found with text: No active Pro subscription was found for this Google Play account. If network/API failure occurs, keep the previous cache value for gating but show restore_error and lastErrorMessage. ScoreReader observes SubscriptionCacheEntity as a Flow so Pro controls unlock immediately after restore_success without requiring app restart.

**Acceptance criteria:**
- On app launch with mocked CustomerInfo containing active pro entitlement, SubscriptionCacheEntity.isProActive becomes true.
- With isProActive true, ScoreReader playback controls are enabled without navigating through ProPurchase.
- Tapping Restore purchases with mocked active pro entitlement changes ProPurchase state to restore_success.
- Tapping Restore purchases with mocked inactive entitlements changes ProPurchase state to restore_no_purchase_found and leaves isProActive false.
- If getCustomerInfo fails after a previous active cache, the app displays subscription_error but does not overwrite the cached active value with false.

### Human billing escalation email

Users can contact a visible billing-support email with optional diagnostics instead of being routed to a scripted chatbot.

- **Answers complaint:** No human when billing breaks

- **Screens:** BillingHelp, ProPurchase, Library

- **Estimated hours:** 20

**Implementation notes:** BillingHelp has a primary button that creates an ACTION_SENDTO intent with Uri.parse("mailto:support@cleftrust.app"), subject "Billing support request", and a body template containing blank lines for What happened, Requested resolution, and Best contact email. If Include diagnostic is checked, append app version, RevenueCat app user ID from SubscriptionCacheEntity, isProActive, latestExpirationAt, and the latest BillingEvent createdAt/eventType; do not attach imported score files. Before starting the intent, call packageManager.resolveActivity. If no email app is available, show email_client_missing and a Copy support email button using ClipboardManager. This screen must not contain chatbot UI, preset-only replies, or a webview support bot.

**Acceptance criteria:**
- Tapping Email support on a device with an email client opens an ACTION_SENDTO mailto intent addressed to support@cleftrust.app.
- The generated subject is exactly Billing support request.
- When Include diagnostic is unchecked, the email body does not contain the RevenueCat app user ID.
- When Include diagnostic is checked, the email body contains isProActive and latest BillingEvent eventType.
- On a device with no email client, BillingHelp shows email_client_missing and displays Copy support email.

## Store listing

- **Title:** ClefTrust Reader
- **Short description:** Honest $4.99/mo sheet music reader with reliable restore.
- **Category:** Music & Audio
- **Keywords:** sheet music, MusicXML, MIDI reader, music practice, public domain scores, score playback, piano sheet music, chords
- **Icon prompt:** Create a modern Android launcher icon for a sheet-music reader named ClefTrust Reader. Use a rounded square background in deep teal #1B6E5C, a centered white music-note glyph combined with two subtle horizontal staff lines, flat vector style, high contrast, no text, no gradients, readable at small sizes.

**Long description:**

Read, import, and practice sheet music without confusing billing. ClefTrust Reader focuses on public-domain scores, MusicXML/MIDI import, playback practice tools, and a clear Pro subscription flow.

What makes it different:
• Public-domain catalog search with no per-score surprise checkout
• MusicXML, compressed MusicXML, and MIDI import
• Clean score reader with playback, tempo control, and chords view
• Pro is clearly labeled as a recurring $4.99/month subscription
• Purchase flow requires an explicit confirmation before Google Play opens
• Restore purchases checks your active Pro access before gating tools
• Billing help opens a real support email path, not a scripted chatbot

The v1 catalog is public-domain only. ClefTrust Reader does not sell individual copyrighted songs and does not convert a one-time score action into a subscription.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://cleftrust.app/privacy (privacy claims verified: no)
- **Data collected:** Google Play/RevenueCat subscription status and purchase identifiers for entitlement restore; RevenueCat app user ID used for billing diagnostics; Optional billing-support email content if the user chooses to send it; Local imported score file URI metadata stored on the device

## Test plan

### 1. Billing converts or renews without clear consent (instrumented)

1. Launch the app with a fake RevenueCat offering containing one monthly package priced at $4.99/month.
2. Navigate to ProPurchase by tapping Manage Pro on Library.
3. Tap Subscribe.
4. Assert that an AlertDialog is displayed.
5. Assert that the dialog text contains recurring $4.99/month subscription.
6. Attempt to tap Continue to Google Play while the checkbox is unchecked.
7. Query the BillingEventEntity table.

**Expected:** No purchase_started BillingEvent exists until after the checkbox is checked and Continue to Google Play is tapped.

### 2. somehow accidentally signed up for the premium lite version when I wanted to buy a piano song for $0.99 (instrumented)

1. Seed Room with one public-domain catalog ScoreEntity whose title is Test Piano Song.
2. Open Catalog.
3. Tap the Test Piano Song row.
4. Wait for ScoreReader to display populated_score.
5. Query the BillingEventEntity table.
6. Check the current navigation route.

**Expected:** The route is score/{scoreId}, no ProPurchase route is opened, and there is no purchase_started BillingEvent.

### 3. Paid content still gated or lost (unit)

1. Create a fake CustomerInfo object wrapper whose entitlements map contains pro with isActive=true.
2. Call the subscription repository refreshCustomerInfo method.
3. Read SubscriptionCacheEntity with id current from the in-memory Room database.
4. Collect the ScoreReader entitlement Flow once.

**Expected:** SubscriptionCacheEntity.isProActive is true and the ScoreReader entitlement Flow emits controlsEnabled=true.

### 4. Purchases that don't restore on a second device (unit)

1. Start with an empty in-memory Room database and no active local cache.
2. Configure the fake Purchases gateway restorePurchases response with active pro entitlement and a non-null expiration date.
3. Call restorePurchases from the ProPurchase ViewModel.
4. Read SubscriptionCacheEntity and the latest BillingEventEntity.

**Expected:** ProPurchase state is restore_success, SubscriptionCacheEntity.isProActive is true, and the latest BillingEventEntity.eventType is restore_succeeded.

### 5. Paid content still gated or lost (unit)

1. Insert SubscriptionCacheEntity with id current and isProActive=true.
2. Configure the fake Purchases gateway getCustomerInfo call to throw a network exception.
3. Call refreshCustomerInfo from the subscription repository.
4. Read SubscriptionCacheEntity with id current.

**Expected:** The cached row still has isProActive=true and lastErrorMessage contains the network exception message.

### 6. No human when billing breaks (instrumented)

1. Launch BillingHelp with SubscriptionCacheEntity containing revenueCatAppUserId app_user_123 and isProActive=false.
2. Check Include diagnostic.
3. Tap Email support.
4. Intercept the ACTION_SENDTO intent with Espresso Intents.

**Expected:** The intercepted intent has data mailto:support@cleftrust.app, subject Billing support request, and body containing app_user_123 and isProActive=false.

### 7. No human when billing breaks (manual)

1. Install the app on a device or emulator with no configured email client.
2. Open Library.
3. Tap Billing help.
4. Tap Email support.

**Expected:** The screen remains in the app, shows email_client_missing, and provides a Copy support email button displaying support@cleftrust.app.

### 8. baseline parity (unit)

1. Load a test MusicXML string containing one part, two measures, treble clef, 4/4 time, and four quarter notes C4 D4 E4 F4.
2. Pass the string to the MusicXmlParser.
3. Read the parsed ScoreDocument.

**Expected:** The parsed ScoreDocument has one part, two measures, four note events, and the first note pitch is C4.

### 9. baseline parity (unit)

1. Load a byte array for a minimal MIDI file beginning with MThd and one MTrk containing tempo, note-on, note-off, and end-of-track events.
2. Pass the byte array to the MidiScoreParser.
3. Read the parsed ScoreDocument event list.

**Expected:** The parsed event list contains one pitched note event with positive durationTick.

### 10. baseline parity (instrumented)

1. Seed Room with three catalog scores: Alpha Sonata by Clara, Beta Waltz by Clara, and Gamma Study by Other.
2. Open Catalog.
3. Type clara into the search field.
4. Read visible catalog row titles.

**Expected:** Alpha Sonata and Beta Waltz are visible, Gamma Study is not visible, and no ProPurchase UI appears.

## Build instructions

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

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
