# ClearThread SMS — build spec

A stable SMS/MMS messenger that keeps sent messages visible, preserves MMS photos, honors one-time lifetime purchases, and never plays audio ads.

## 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:** Mood SMS - Messages App
- **Package id:** `com.calea.echo`
- **Google Play:** https://play.google.com/store/apps/details?id=com.calea.echo
- **appy.fyi report:** https://appy.fyi/report/com.calea.echo
- **Category:** Communication

## Overview

- **Working name:** ClearThread SMS (trademark cleared: no)
- **Package id:** `fyi.appy.clearthreadsms`
- **Min / target SDK:** 23 / 35
- **Backend:** none
- **Estimated build time:** 5.75 weeks
- **Pricing:** one-time purchase, $4.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `POST_NOTIFICATIONS`

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

- RCS messaging support is out of scope for v1; the app is explicitly SMS/MMS only.
- AI reply suggestions are out of scope because the report says AI is not what wins back these users.
- Cloud sync, user accounts, and server-side message storage are out of scope for v1.
- Autoplay video ads, audio ads, interstitial ads, and ads inside message threads are out of scope permanently.
- Advanced carrier-specific MMS tuning beyond the Android platform APIs and the selected SMS/MMS transport library is out of scope for v1.

## 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 for MainActivity | `androidx.activity:activity-compose:1.9.3` |
| Compose Navigation graph for onboarding, conversations, threads, settings, backup, themes, and premium screens | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware collection of ViewModel state in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| ViewModel support for Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Room local database for app-owned theme presets, backup metadata, entitlement cache, and delivery regression probe rows | `androidx.room:room-runtime:2.6.1` |
| Kotlin coroutine extensions for Room DAO operations | `androidx.room:room-ktx:2.6.1` |
| KSP annotation processing for Room | `androidx.room:room-compiler:2.6.1` |
| Typed local preferences for current theme id, onboarding completion, and last successful purchase refresh time | `androidx.datastore:datastore-preferences:1.1.1` |
| Display MMS image parts from content URIs without forced downscaling | `io.coil-kt:coil-compose:2.7.0` |
| One-time Google Play Billing purchase and restore flow for the lifetime premium unlock | `com.android.billingclient:billing-ktx:7.1.1` |
| SMS/MMS transport, MMS PDU creation, and carrier APN handling for default SMS/MMS app behavior | `com.klinkerapps:android-smsmms:5.2.6` |
| Runtime notification permission handling and Android compatibility helpers | `androidx.core:core-ktx:1.13.1` |

## Design system

- **Primary color:** `#2563EB`
- **Background color:** `#F8FAFC`
- **Error color:** `#DC2626`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `chat-circle-text` (regular weight)
- **Theme notes:** Use Material 3 dynamic color only when the user selects the built-in 'System' theme; otherwise apply the chosen theme's primary/background colors exactly. Light mode default is #F8FAFC background with #111827 text; dark mode default is #0F172A background with #E5E7EB text. Message bubbles: outgoing uses primary color with white text, incoming uses #E5E7EB in light mode and #334155 in dark mode. Never use incumbent branding colors or artwork.

## Screens

### DefaultSmsOnboarding
- **Route:** `onboarding`
- **Purpose:** Guide the user to make the app the default SMS app and grant notification permission before entering the inbox.
- **Reached via:** app launch when app is not the default SMS handler; tap 'Default SMS setup' from Settings
- **Key UI elements:** App logo and 'Make ClearThread your SMS app' headline; Default SMS role status row; Request default SMS app button; Notification permission status row for Android 13+; Continue button enabled only after default SMS role is granted; SMS/MMS only disclosure text stating that RCS is not supported in v1
- **States:** loading, needs_default_sms_role, needs_notification_permission, ready, error

### ConversationList
- **Route:** `conversations`
- **Purpose:** Show all SMS/MMS threads and provide entry to search, compose, themes, backup, settings, and premium.
- **Reached via:** app launch after onboarding is complete; back from Thread; back from ComposeNew; bottom-level navigation return from Settings, Themes, BackupRestore, or Premium
- **Key UI elements:** Top app bar with title; Search field; LazyColumn of conversation rows with contact/number, last message preview, timestamp, unread badge, and MMS indicator; Static premium upgrade card for unpaid users outside the thread list header area; Floating action button to compose a new message; Overflow menu linking to Themes, Backup & Restore, Premium, and Settings
- **States:** loading, empty, error, populated, search_no_results

### Thread
- **Route:** `thread/{threadId}`
- **Purpose:** Display a single SMS/MMS conversation and send new SMS or MMS messages without hiding local sent messages.
- **Reached via:** tap a conversation row on ConversationList; tap an incoming SMS/MMS notification; successful recipient selection from ComposeNew
- **Key UI elements:** Top app bar with recipient name or phone number; LazyColumn of chronological message bubbles; Outgoing delivery state labels: queued, sending, sent, delivered, failed; MMS image thumbnails loaded from original content URI; Message composer text field; Attach image button using the Android Photo Picker; Send button; Retry failed send button on failed outgoing messages
- **States:** loading, empty, error, populated, sending, send_failed, media_loading, media_error

### ComposeNew
- **Route:** `compose`
- **Purpose:** Start a new SMS/MMS conversation with one or more phone-number recipients.
- **Reached via:** tap compose floating action button on ConversationList
- **Key UI elements:** Recipient phone number input; Recipient chips; Message text field; Attach image button using Android Photo Picker; Send button; Validation message for empty recipient or empty message/body attachment
- **States:** empty, editing, validation_error, sending, send_failed

### ThemeGallery
- **Route:** `themes`
- **Purpose:** Let users customize the SMS app appearance without affecting message delivery behavior.
- **Reached via:** tap 'Themes' from ConversationList overflow menu; tap 'Themes' from Settings
- **Key UI elements:** Theme preset grid; Preview conversation card; Primary color picker with fixed swatches; Background mode selector: system, light, dark; Save theme button; Reset to default button
- **States:** loading, populated, saving, error

### BackupRestore
- **Route:** `backup`
- **Purpose:** Export and import SMS/MMS messages plus app theme/settings using Android document picker storage.
- **Reached via:** tap 'Backup & Restore' from ConversationList overflow menu; tap 'Backup & Restore' from Settings
- **Key UI elements:** Last backup timestamp; Export backup button; Import backup button; Progress bar with current item count; Conflict policy selector: skip existing or overwrite app settings; Result summary showing exported/imported messages, MMS parts, and themes; Error panel with failed item count
- **States:** idle, exporting, importing, completed, error, cancelled

### Premium
- **Route:** `premium`
- **Purpose:** Sell and restore the one-time lifetime premium unlock, making permanent entitlement status visible.
- **Reached via:** tap static premium card on ConversationList; tap 'Premium' from ConversationList overflow menu; tap 'Premium' from Settings
- **Key UI elements:** Premium status card showing Free or Lifetime unlocked; $4.99 one-time purchase button; Restore purchases button; Last successful entitlement check timestamp; Purchase error message area; No audio ads ever pledge text
- **States:** loading, free, purchase_in_progress, unlocked, restore_in_progress, billing_unavailable, error

### Settings
- **Route:** `settings`
- **Purpose:** Expose app status, delivery test access, notification settings, default SMS status, and navigation to other configuration screens.
- **Reached via:** tap 'Settings' from ConversationList overflow menu
- **Key UI elements:** Default SMS app status row; Run delivery self-test button; Notification permission row; Premium status row; Themes row; Backup & Restore row; SMS/MMS only and no RCS disclosure; Privacy policy link placeholder
- **States:** loading, populated, self_test_running, self_test_passed, self_test_failed, error

## Data model

### ThemePreset (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| name | `String` | unique display name |
| primaryColorHex | `String` | 7-character #RRGGBB string |
| backgroundColorHex | `String` | 7-character #RRGGBB string |
| darkMode | `Boolean` | true when this preset forces dark mode |
| createdAtEpochMillis | `Long` | System.currentTimeMillis at creation |

### BackupRecord (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| direction | `String` | EXPORT or IMPORT |
| documentUri | `String` | SAF Uri string selected by the user |
| startedAtEpochMillis | `Long` |  |
| finishedAtEpochMillis | `Long` | 0 until completed |
| messageCount | `Int` | number of SMS/MMS rows processed |
| mmsPartCount | `Int` | number of MMS parts processed |
| status | `String` | RUNNING, COMPLETED, ERROR, or CANCELLED |
| errorMessage | `String?` | nullable |

### PurchaseEntitlementCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| productId | `String` | primary key; expected value premium_lifetime |
| isEntitled | `Boolean` | true only after BillingClient reports a purchased non-consumable |
| purchaseTokenHash | `String?` | nullable SHA-256 hash of purchase token, not the raw token |
| lastCheckedAtEpochMillis | `Long` | last successful queryPurchasesAsync time |

### DeliveryProbe (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| threadId | `Long` | Telephony thread id used in the probe |
| body | `String` | probe message body |
| expectedMessageUuid | `String` | client-generated UUID inserted into local sent row metadata/body marker |
| insertedAtEpochMillis | `Long` |  |
| observedInProvider | `Boolean` | true when the sent row is found in Telephony provider after send lifecycle |
| finalStatus | `String` | QUEUED, SENT, DELIVERED, FAILED, or MISSING |

## Features

### Default SMS/MMS inbox and thread handling

Make the app usable as the Android default SMS/MMS handler with inbox, thread view, compose, send, receive, retry, and notifications.

- **Answers complaint:** baseline parity

- **Screens:** DefaultSmsOnboarding, ConversationList, Thread, ComposeNew, Settings

- **Estimated hours:** 70

**Implementation notes:** Declare the Android default SMS app manifest components and permissions required by Android outside the structured permissions enum: READ_SMS, SEND_SMS, RECEIVE_SMS, RECEIVE_MMS, RECEIVE_WAP_PUSH, BROADCAST_SMS, BROADCAST_WAP_PUSH, and READ_PHONE_STATE only if the selected MMS transport path requires it. On Android 10+ request RoleManager.ROLE_SMS from DefaultSmsOnboarding; on Android 6-9 launch Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT with Telephony.Sms.Intents.EXTRA_PACKAGE_NAME. Read conversations from content://mms-sms/conversations?simple=true and individual messages by querying content://sms and content://mms filtered by thread_id. For sending SMS use SmsManager.sendTextMessage with sent and delivered PendingIntents, and insert a local outgoing row immediately into Telephony.Sms.Sent or Telephony.Sms.Outbox before sending so the bubble appears even if carrier callbacks are delayed. For MMS sending and receiving, use com.klinkerapps.android-smsmms Transaction APIs to build and send MMS PDUs and to parse MMS configuration/APN values. Incoming SMS is handled in the default SMS receiver, inserted through Telephony provider, then ConversationList and Thread are refreshed through ContentObserver on Telephony.Sms.CONTENT_URI and Telephony.Mms.CONTENT_URI. On Android 13+ request POST_NOTIFICATIONS before posting incoming-message notifications; if denied, still receive and store messages but do not post notifications.

**Acceptance criteria:**
- When the app is not the default SMS app, app launch shows DefaultSmsOnboarding instead of ConversationList.
- After the user grants the default SMS role, ConversationList loads conversations from the platform SMS/MMS provider.
- Tapping a conversation row opens Thread with messages ordered oldest to newest.
- Sending a text message creates an outgoing bubble immediately with status queued or sending before carrier callback completion.
- A sent callback changes the outgoing bubble status to sent without removing the bubble.
- A failed callback leaves the outgoing bubble visible with status failed and shows a retry action.
- Incoming SMS received while the app is foregrounded appears in the correct Thread without restarting the app.
- Incoming SMS received while the app is backgrounded is stored and posts a notification only when POST_NOTIFICATIONS is granted.

### Never-hide sent message delivery state machine

Prevent the reported regression where users' own sent messages disappear or no longer show that they sent.

- **Answers complaint:** Sent messages disappearing from the thread, MMS/photos arriving pixelated or not at all, after recent updates — the most common and most damaging complaint.

- **Screens:** Thread, Settings

- **Estimated hours:** 28

**Implementation notes:** Implement a MessageSendState state machine with states DRAFT, QUEUED_LOCAL, SENDING, SENT_PROVIDER_CONFIRMED, DELIVERED_CALLBACK_CONFIRMED, FAILED_RETRYABLE, and FAILED_FINAL. The invariant is that once a message has entered QUEUED_LOCAL, the Thread UI must render it from either the Telephony provider row or an in-memory pending-send overlay keyed by a UUID until a provider row is found; no transition is allowed to remove the local bubble except explicit user deletion. Insert the outgoing provider row before transport send with date=System.currentTimeMillis, read=1, seen=1, type=Telephony.Sms.MESSAGE_TYPE_OUTBOX for sending, then update type to MESSAGE_TYPE_SENT after sent callback. Register a ContentObserver and reconcile rows by address, timestamp window of plus/minus 10 seconds, body, and UUID marker when present. The delivery self-test in Settings creates a fake transport in debug/test builds, drives queued -> sending -> sent -> delivered and queued -> sending -> failed transitions, and asserts the UI model contains the outgoing message after each transition.

**Acceptance criteria:**
- A queued outgoing message remains visible after the fake transport emits a delayed sent callback.
- A queued outgoing message remains visible after the fake transport emits a failed callback.
- The Thread UI never renders an empty list after a send starts if it contained the outgoing message immediately before the send.
- The Settings delivery self-test shows self_test_passed only after both success and failure state-machine paths preserve the outgoing bubble.
- Unit tests fail if any state transition from QUEUED_LOCAL, SENDING, SENT_PROVIDER_CONFIRMED, or FAILED_RETRYABLE produces a UI model without that message id.

### Full-fidelity MMS photo receive and display

Display received MMS photos from their original MMS parts without app-side pixelation or silent loss.

- **Answers complaint:** MMS/photos arriving pixelated or not at all

- **Screens:** Thread, ComposeNew, BackupRestore

- **Estimated hours:** 24

**Implementation notes:** For each MMS message, query content://mms/part where mid equals the MMS id. Treat parts with content_type beginning image/ as attachments. Store no recompressed copy. Render thumbnails in Thread with Coil AsyncImage using the part content Uri content://mms/part/{partId}; set ImageRequest.Builder.size(coil.size.Size.ORIGINAL) for the detail viewer path and a bounded 160dp thumbnail size only for the list bubble. If Coil returns an error, show a media_error placeholder with the MIME type and part id instead of hiding the message. Backup export streams the exact part bytes from ContentResolver.openInputStream(partUri) into the backup zip under mms_parts/{messageId}_{partId} without bitmap decoding. Sending an attached image uses Android Photo Picker and passes the selected Uri stream to the MMS library without resizing unless the carrier/library rejects the PDU size; if rejection occurs, mark the message failed and keep the original attachment visible for retry rather than silently sending a pixelated version.

**Acceptance criteria:**
- A received MMS image part with MIME type image/jpeg appears as a thumbnail in Thread.
- If image decoding fails, Thread shows a visible media error placeholder instead of omitting the MMS bubble.
- Backup export writes the MMS part byte-for-byte from the part content URI without bitmap compression.
- Attaching an image through Android Photo Picker shows a preview before send.
- If MMS send fails due to transport rejection, the outgoing bubble remains visible with failed status and the original attachment preview still shown.

### No audio ads and no in-thread ads

Avoid the reported ad behavior by using no ad SDK, no audio playback, and only a static premium upgrade surface outside conversations.

- **Answers complaint:** Loud, unmutable audio ads Ads that override music/other audio mid-conversation, reported even by users who already paid.

- **Screens:** ConversationList, Thread, Premium

- **Estimated hours:** 8

**Implementation notes:** Do not add any advertising SDK dependency, WebView ad container, MediaPlayer, ExoPlayer, or audio focus request to the app. For unpaid users, render a static Compose premium upgrade card only at the top of ConversationList and on Premium; never render promotional content inside Thread. When PurchaseEntitlementCache.isEntitled is true, hide the static card everywhere. Add a code-level UI test tag 'premium_static_card' to the card and no test tags or composables for audio/video ads. This directly satisfies the complaint by making audio ads technically impossible in v1.

**Acceptance criteria:**
- Thread contains no premium card, banner, interstitial, WebView, or autoplay media composable for free users.
- ConversationList may show exactly one static premium card for free users.
- When lifetime premium is unlocked, ConversationList and Premium do not show upgrade prompts.
- The project has no dependency whose group or artifact name contains 'ads', 'admob', or 'exoplayer'.
- The app codebase contains no AudioManager.requestAudioFocus call and no MediaPlayer instantiation.

### Lifetime one-time premium unlock and restore

Sell a $4.99 non-consumable lifetime unlock and restore it permanently across reinstalls and devices through Google Play Billing.

- **Answers complaint:** Revoked lifetime purchases Years-old paying customers suddenly seeing ads and being asked to pay again after a reinstall or new phone.

- **Screens:** Premium, ConversationList, Settings

- **Estimated hours:** 20

**Implementation notes:** Create one managed product in Play Console named premium_lifetime priced at $4.99. Use BillingClient with enablePendingPurchases, queryProductDetailsAsync for premium_lifetime, launchBillingFlow from Premium, acknowledge purchases after PURCHASED state, and queryPurchasesAsync(BillingClient.ProductType.INAPP) on every cold start and when the user taps Restore purchases. Set PurchaseEntitlementCache.isEntitled=true when any returned purchase has product premium_lifetime and purchaseState PURCHASED; never set it false on transient BillingResponseCode.SERVICE_UNAVAILABLE, NETWORK_ERROR, or ERROR. Only set false when queryPurchasesAsync succeeds and returns no purchased premium_lifetime item. Cache lastCheckedAtEpochMillis and display it on Premium. This uses Play account ownership for reinstall/new-phone restore without a custom backend.

**Acceptance criteria:**
- Premium shows a $4.99 one-time purchase button when no entitlement is cached.
- After a successful test purchase, Premium shows Lifetime unlocked.
- After app process restart, the cached entitlement still hides upgrade prompts before the next billing refresh completes.
- Tapping Restore purchases calls queryPurchasesAsync and sets Lifetime unlocked when Play returns premium_lifetime as PURCHASED.
- A simulated network error during restore does not revoke an already cached entitlement.
- A successful query returning no premium_lifetime purchase changes the status to Free.

### SMS theming and customization engine

Provide the SMS customization baseline expected by users through saved color themes and conversation previews.

- **Answers complaint:** baseline parity

- **Screens:** ThemeGallery, ConversationList, Thread, Settings

- **Estimated hours:** 32

**Implementation notes:** Seed Room with four ThemePreset rows on first launch: Default Blue (#2563EB/#F8FAFC), Slate Dark (#38BDF8/#0F172A), Forest (#16A34A/#F0FDF4), and Rose (#E11D48/#FFF1F2). Store the selected theme id in DataStore key selected_theme_id. ThemeGallery lets users pick one preset or create a custom preset from fixed primary swatches #2563EB, #16A34A, #E11D48, #7C3AED, #EA580C and background swatches #F8FAFC, #FFFFFF, #F0FDF4, #FFF1F2, #0F172A. Apply the selected theme through a single AppTheme composable wrapping the NavHost; message bubble colors derive from the selected primary color for outgoing and neutral surface for incoming. Do not let theme changes touch message sending, provider queries, or delivery state-machine code.

**Acceptance criteria:**
- First launch creates the four named preset themes exactly once.
- Selecting Forest changes outgoing message bubbles to #16A34A.
- Creating a custom theme persists it in Room and it remains available after app restart.
- Reset to default sets selected_theme_id to the Default Blue preset.
- Sending a message after changing themes uses the same delivery state-machine path as before the theme change.

### Local backup and restore

Export and import SMS/MMS content plus app settings through user-selected local files without cloud accounts.

- **Answers complaint:** baseline parity

- **Screens:** BackupRestore, Settings

- **Estimated hours:** 28

**Implementation notes:** Use Android Storage Access Framework only: ACTION_CREATE_DOCUMENT for export and ACTION_OPEN_DOCUMENT for import. Export format is a zip file with manifest.json, sms.jsonl, mms.jsonl, mms_parts/*, and themes.json. sms.jsonl contains one JSON object per SMS provider row with address, body, date, type, read, seen, status, and thread_id. mms.jsonl contains MMS metadata rows and references to mms_parts filenames. Stream MMS parts with ContentResolver.openInputStream and never decode/recompress image bytes. Import reads the zip sequentially on Dispatchers.IO, skips SMS rows whose address/body/date already exist within a 2-second date window, inserts non-duplicates into Telephony provider only while the app is default SMS handler, and imports ThemePreset rows by name with suffix ' imported' on collision. Record every run in BackupRecord and show counts and errors in BackupRestore.

**Acceptance criteria:**
- Export creates a zip document selected by the user through ACTION_CREATE_DOCUMENT.
- Export result summary shows nonzero SMS count when the provider contains SMS rows.
- MMS image parts are present under mms_parts/ in the zip when MMS image messages exist.
- Import skips duplicate SMS messages using address, body, and date within a 2-second window.
- Import refuses to write messages and shows an error if the app is not the default SMS handler.
- A completed export or import creates a BackupRecord with status COMPLETED.

### Release reliability regression test harness

Add automated tests that prevent releases from regressing message delivery visibility, purchase restore, MMS image handling, and no-audio-ad guarantees.

- **Answers complaint:** Treat send/receive reliability as never-regress. Every release should run behind a message-delivery test — it's the exact thing users are leaving over.

- **Screens:** Thread, Premium, Settings

- **Estimated hours:** 20

**Implementation notes:** Create a test-only SmsTransport interface with FakeSmsTransport implementations for success, delayed success, and failure. Unit-test MessageSendStateReducer transitions with JUnit. Add Compose UI tests for Thread using a fake repository that emits a pending outgoing message followed by delayed sent status, then verify the message text remains visible. Add a BillingRepository fake that returns purchased, empty, and network-error query results to test entitlement revocation rules. Add a static dependency/code scan Gradle task named verifyNoAudioAds that fails if runtimeClasspath contains artifacts with 'ads', 'admob', or 'exoplayer' or if source contains 'MediaPlayer(' or 'requestAudioFocus'. Wire check to depend on verifyNoAudioAds and the unit tests.

**Acceptance criteria:**
- Running ./gradlew testDebugUnitTest executes MessageSendStateReducer tests.
- The delayed-success send test fails if the outgoing message disappears before the sent callback.
- The failed-send test fails if the outgoing message disappears after failure.
- The billing network-error test fails if an existing entitlement is revoked after a transient error.
- Running ./gradlew verifyNoAudioAds fails when a dependency artifact contains 'admob'.
- Running ./gradlew check runs verifyNoAudioAds automatically.

## Store listing

- **Title:** ClearThread SMS
- **Short description:** Stable SMS/MMS themes, no audio ads, lifetime unlock.
- **Category:** Communication
- **Keywords:** sms, mms, text messaging, sms themes, mms photos, no audio ads, backup restore, lifetime unlock
- **Icon prompt:** Create a modern Android launcher icon for an SMS/MMS messaging app named ClearThread SMS. Use a rounded square background in deep blue #2563EB with a centered white chat bubble containing three short horizontal message lines. Flat vector style, high contrast, no gradients, no brand references, no letters, no shadows, suitable at small launcher sizes.

**Long description:**

ClearThread SMS is built for people who want a customizable SMS/MMS app that does not lose their own sent messages, does not mangle MMS photos, and does not interrupt conversations with audio ads.

Core promises:
• Sent messages stay visible with clear queued, sent, delivered, and failed states.
• MMS photos are displayed from their original message parts instead of being silently hidden.
• No autoplay audio ads, no in-thread ads, and no ad SDK in the app.
• A one-time $4.99 lifetime unlock restored through Google Play purchases.
• Local backup and restore using files you choose on your device.
• SMS/MMS theme customization with saved color presets.

ClearThread is SMS/MMS only in v1. RCS messaging is not supported.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://clearthreadsms.example.com/privacy (privacy claims verified: no)
- **Data collected:** SMS and MMS message content stored locally on the device; Phone numbers and conversation metadata stored locally on the device; MMS image attachments stored locally through the Android messaging provider; Google Play premium entitlement status cached locally on the device

## Test plan

### 1. Sent messages disappearing from the thread (unit)

1. Create a MessageSendStateReducer instance.
2. Create an outgoing message model with id 'local-1', body 'delivery regression test', and initial state QUEUED_LOCAL.
3. Apply transition QUEUED_LOCAL to SENDING.
4. Build the Thread UI model from the reducer output.
5. Apply transition SENDING to SENT_PROVIDER_CONFIRMED.
6. Build the Thread UI model again.

**Expected:** The UI model contains message id 'local-1' and body 'delivery regression test' after both transitions.

### 2. Now my text messages won't show that they sent (unit)

1. Create an outgoing message model with id 'local-2' and body 'status label test'.
2. Apply transitions QUEUED_LOCAL, SENDING, and SENT_PROVIDER_CONFIRMED.
3. Map each reducer state to the Thread bubble delivery label.

**Expected:** The visible label sequence is exactly 'queued', then 'sending', then 'sent', and the message remains present for every state.

### 3. Revoked lifetime purchases (unit)

1. Seed PurchaseEntitlementCache with productId 'premium_lifetime' and isEntitled true.
2. Call BillingRepository.refreshEntitlement with a fake result BillingResponseCode.NETWORK_ERROR.
3. Read PurchaseEntitlementCache again.

**Expected:** isEntitled remains true and lastCheckedAtEpochMillis is not advanced by the failed network result.

### 4. Revoked lifetime purchases after reinstall or new phone (unit)

1. Seed PurchaseEntitlementCache empty.
2. Call BillingRepository.refreshEntitlement with a fake successful query containing one PURCHASED purchase for product 'premium_lifetime'.
3. Read PurchaseEntitlementCache.
4. Build Premium screen state from the repository.

**Expected:** PurchaseEntitlementCache.isEntitled is true and Premium screen state is unlocked.

### 5. Sent messages disappearing from the thread (instrumented)

1. Launch Thread with a fake thread id and fake repository containing one incoming message.
2. Enter text 'instrumented sent message'.
3. Tap the send button.
4. Configure FakeSmsTransport to delay the sent callback by 3 seconds.
5. Immediately query the Compose tree for text 'instrumented sent message'.
6. Advance the fake callback and query the Compose tree for the same text again.

**Expected:** The text 'instrumented sent message' is visible immediately after tapping send and remains visible after the delayed sent callback.

### 6. MMS/photos arriving pixelated or not at all (instrumented)

1. Launch Thread with a fake MMS message containing an image part Uri served by a test ContentProvider.
2. Make the test ContentProvider return a valid 1200x800 JPEG byte stream for content://mms/part/42.
3. Wait for the Compose image node tagged 'mms_part_42' to load.
4. Repeat with the provider returning invalid image bytes for content://mms/part/43.

**Expected:** For part 42, the image node tagged 'mms_part_42' is displayed; for part 43, a visible media error placeholder containing 'part 43' is displayed.

### 7. Loud, unmutable audio ads (instrumented)

1. Launch Thread as a free user with entitlement false.
2. Search the Compose tree for node tag 'premium_static_card'.
3. Search the Compose tree for any node tag containing 'audio_ad', 'video_ad', or 'interstitial_ad'.
4. Launch ConversationList as a free user.
5. Search for node tag 'premium_static_card'.

**Expected:** Thread contains no premium_static_card and no audio/video/interstitial ad nodes; ConversationList contains exactly one premium_static_card.

### 8. Loud, unmutable audio ads overriding music (manual)

1. Start music playback in a separate music app.
2. Open ClearThread SMS as a free user.
3. Navigate to ConversationList.
4. Open any Thread.
5. Spend 60 seconds scrolling and typing in the Thread.
6. Return to ConversationList and open Premium.

**Expected:** Music playback is never paused, ducked, interrupted, or overlaid by audio from ClearThread SMS.

### 9. baseline parity (manual)

1. Install the app fresh.
2. Grant the default SMS role from onboarding.
3. Open ConversationList.
4. Open ThemeGallery.
5. Select the Forest theme.
6. Return to a Thread and send a short SMS to a test phone number.
7. Observe the outgoing bubble color and delivery status.

**Expected:** The outgoing bubble uses #16A34A and the message remains visible through queued, sending, and sent status.

### 10. baseline parity backup/restore (manual)

1. With the app as default SMS handler, open BackupRestore.
2. Tap Export backup.
3. Choose a local document location and filename clearthread-test.zip.
4. Wait for export completion.
5. Open the zip with a file manager or desktop unzip tool.
6. Confirm manifest.json, sms.jsonl, mms.jsonl, themes.json, and any expected mms_parts files are present.

**Expected:** BackupRestore shows completed status with message counts, and the zip contains the documented files.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew check
./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
