# ClearThread Messenger — build spec

A clean SMS/MMS replacement that never inserts ads as fake conversations, avoids third-party ad/data SDKs, and makes RCS availability explicit instead of silently breaking group chats.

## 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:** Handcent Next SMS messenger
- **Package id:** `com.handcent.app.nextsms`
- **Google Play:** https://play.google.com/store/apps/details?id=com.handcent.app.nextsms
- **appy.fyi report:** https://appy.fyi/report/com.handcent.app.nextsms
- **Category:** Communication

## Overview

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

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

- No ads in the conversation list or inside message threads in v1, even for free users.
- No AI smart replies, AI spam filtering, or LLM calls in v1 because the report says AI is not the reason users are switching.
- No independent carrier-grade RCS network or private RCS server in v1; RCS handling is limited to device/client availability checks, explicit SMS fallback, and handoff to an installed RCS-capable client where Android does not expose send APIs.
- No cloud sync, accounts, web messaging portal, or server-side message storage.
- No bundled third-party advertising, analytics, attribution, or data-broker SDKs.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose BOM for consistent Compose dependency versions | `androidx.compose:compose-bom:2024.10.01` |
| Compose runtime and UI primitives | `androidx.compose.ui:ui:1.7.5` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose preview and tooling support | `androidx.compose.ui:ui-tooling-preview:1.7.5` |
| Activity integration for setContent and Activity Result APIs | `androidx.activity:activity-compose:1.9.3` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware state collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Core Android Kotlin extensions including RoleManager compatibility helpers | `androidx.core:core-ktx:1.13.1` |
| Room local database runtime for categories, themes, and entitlement cache | `androidx.room:room-runtime:2.6.1` |
| Room coroutine extensions | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor for generated DAO implementations | `androidx.room:room-compiler:2.6.1` |
| Preferences DataStore for lightweight app settings such as first-run flags | `androidx.datastore:datastore-preferences:1.1.1` |
| Play Billing one-time unlock purchase and restore | `com.android.billingclient:billing-ktx:7.1.1` |
| JSON backup and restore serialization | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Contact avatar and user-selected background URI loading in Compose | `io.coil-kt:coil-compose:2.7.0` |

## Design system

- **Primary color:** `#146C60`
- **Background color:** `#FAF8F3`
- **Error color:** `#B3261E`
- **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 enables it in Theme Editor; otherwise use #146C60 primary, #FAF8F3 light background, and #101816 text. Dark mode uses #0E1513 background, #8FD8CA primary, and #E6F1ED text. Conversation bubbles default to primary for outgoing and #E7ECE8 for incoming in light mode, with per-contact overrides saved locally.

## Screens

### ConversationList
- **Route:** `conversations`
- **Purpose:** Main inbox showing real SMS/MMS threads, category filters, default-SMS status, and no ad-shaped conversation rows.
- **Reached via:** app launch; back from ConversationThread; back from Settings; notification tap for an incoming SMS or MMS
- **Key UI elements:** Top app bar with app title and Settings action; Default SMS required card when app is not default SMS handler; Horizontal category filter chips; LazyColumn of conversation rows from Telephony provider; Floating compose button; Static non-thread promotional card only when placed below all conversation content and never styled as a contact
- **States:** loading, role_required, empty, error, populated

### ConversationThread
- **Route:** `thread/{threadId}`
- **Purpose:** Read and send messages in one SMS/MMS thread, show RCS handoff when the thread should use an RCS-capable client, and keep the thread ad-free.
- **Reached via:** tap a row on ConversationList; tap an incoming message notification; tap restored thread after backup import
- **Key UI elements:** Top app bar with contact or group title; RCS unavailable or Open RCS Client banner when applicable; LazyColumn of message bubbles; Attachment preview strip; Message text field; Send button; MMS attachment picker button; Per-contact theme applied to background and bubbles
- **States:** loading, not_default_sms, empty, rcs_handoff_available, send_error, error, populated

### ComposeMessage
- **Route:** `compose?address={address}`
- **Purpose:** Start a new SMS/MMS conversation or continue with a supplied address.
- **Reached via:** tap compose floating button on ConversationList; Android ACTION_SENDTO smsto intent; tap forward/share into the app
- **Key UI elements:** Recipient address field; Resolved recipient chips; Message text field; Attachment picker button; Send button; Validation error text for missing or invalid recipient
- **States:** empty_draft, recipient_prefilled, validation_error, sending, send_error

### Categories
- **Route:** `categories`
- **Purpose:** Create folders/categories and assign SMS/MMS threads to them.
- **Reached via:** tap Categories from Settings; tap category management icon from ConversationList
- **Key UI elements:** List of categories with color dot and thread count; Add category button; Rename and delete menu per category; Thread assignment checklist dialog
- **States:** loading, empty, edit_dialog, delete_confirm, error, populated

### ThemeEditor
- **Route:** `theme?threadId={threadId}`
- **Purpose:** Configure global colors and per-contact/per-thread backgrounds, with full theming behind the one-time unlock.
- **Reached via:** tap Theme from Settings; tap Customize from ConversationThread overflow menu; tap Unlock theming promotional card
- **Key UI elements:** Premium locked card when entitlement is inactive; Primary color hex field; Outgoing bubble color hex field; Incoming bubble color hex field; Use dynamic color switch; Background image picker using Android Photo Picker; Preview conversation card; Save button
- **States:** loading, locked, editing_global, editing_thread, validation_error, saved, error

### BackupRestore
- **Route:** `backup`
- **Purpose:** Export and import local app data such as categories and theme settings without cloud accounts.
- **Reached via:** tap Backup and restore from Settings
- **Key UI elements:** Export backup button; Import backup button; Last backup result text; Progress indicator with exact percent; Conflict summary before import; Restore confirmation dialog
- **States:** idle, exporting, import_preview, importing, success, error

### Purchase
- **Route:** `purchase`
- **Purpose:** Sell and restore the one-time $4.99 full-theming unlock.
- **Reached via:** tap Unlock from ThemeEditor; tap Upgrade from Settings; tap Restore purchases from Settings
- **Key UI elements:** $4.99 one-time unlock card; Benefits list limited to full theming; Buy button; Restore purchases button; Billing error text; Purchased confirmation
- **States:** loading_product, available, purchase_in_progress, purchased, restore_empty, billing_error

### Settings
- **Route:** `settings`
- **Purpose:** Central settings hub for default SMS role, categories, theming, backup, purchase restore, and security transparency.
- **Reached via:** tap Settings from ConversationList; tap notification action to fix default SMS role
- **Key UI elements:** Default SMS status row; Categories row; Theme row; Backup and restore row; Upgrade or purchased status row; Security transparency row stating no ad/data SDKs; Static free-tier banner area that is visually distinct from contacts
- **States:** loading, free, paid, not_default_sms, error

## Data model

### Category (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| name | `String` | unique, max 32 visible characters |
| colorHex | `String` | validated #RRGGBB |
| sortOrder | `Int` | ascending display order |
| createdAtEpochMillis | `Long` | System.currentTimeMillis when created |

### ThreadCategoryCrossRef (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| threadId | `Long` | Telephony.Threads._ID |
| categoryId | `Long` | foreign key to Category.id |

### ThreadTheme (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| threadId | `Long` | primary key, Telephony.Threads._ID |
| outgoingBubbleHex | `String` | nullable encoded as empty string for default; validated #RRGGBB when non-empty |
| incomingBubbleHex | `String` | nullable encoded as empty string for default; validated #RRGGBB when non-empty |
| backgroundColorHex | `String` | nullable encoded as empty string for default; validated #RRGGBB when non-empty |
| backgroundImageUri | `String` | nullable encoded as empty string; persisted URI from Android Photo Picker |
| updatedAtEpochMillis | `Long` | System.currentTimeMillis when saved |

### GlobalTheme (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 |
| primaryColorHex | `String` | validated #RRGGBB |
| useDynamicColor | `Boolean` | true only when user enables dynamic color |
| updatedAtEpochMillis | `Long` | System.currentTimeMillis when saved |

### EntitlementCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| productId | `String` | primary key; use clearthread_full_theming |
| isOwned | `Boolean` | true only after Play Billing purchase or restore confirms ownership |
| purchaseTokenHash | `String` | SHA-256 of purchase token, never store raw token |
| lastVerifiedAtEpochMillis | `Long` | System.currentTimeMillis after queryPurchasesAsync success |

## Features

### Default SMS/MMS handler

Let the app become the default SMS handler, display existing SMS/MMS threads, receive new messages, and send text or multimedia messages.

- **Answers complaint:** baseline parity

- **Screens:** ConversationList, ConversationThread, ComposeMessage

- **Estimated hours:** 72

**Implementation notes:** Declare the SMS default-role manifest components required by Android: an activity handling ACTION_SENDTO for smsto/sms/mms/mmsto, receivers for SMS_DELIVER_ACTION and WAP_PUSH_DELIVER_ACTION with MMS MIME type, and a service for RESPOND_VIA_MESSAGE. On launch call RoleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) when RoleManager.isRoleHeld is false. Query conversations through ContentResolver using Telephony.Threads.CONTENT_URI sorted by DATE DESC; for each thread query Telephony.Sms.CONTENT_URI and Telephony.Mms.CONTENT_URI by THREAD_ID to build a unified message list sorted by date. Send plain text with SmsManager.getDefault().sendTextMessage(address, null, body, sentPendingIntent, deliveredPendingIntent). For MMS, write the attachment stream to cache, expose it through a FileProvider content URI, and call SmsManager.getDefault().sendMultimediaMessage(context, contentUri, null, null, sentPendingIntent). Register a ContentObserver on Telephony.Sms.CONTENT_URI and Telephony.Mms.CONTENT_URI and refresh the affected thread on change.

**Acceptance criteria:**
- When the app is not the default SMS app, ConversationList shows the role_required state and a button that launches the Android default SMS role request.
- After the app holds ROLE_SMS, ConversationList displays Telephony threads sorted newest first.
- Sending a plain text message calls SmsManager.sendTextMessage and inserts the resulting sent message into the visible thread after provider refresh.
- Selecting one image attachment and sending calls SmsManager.sendMultimediaMessage with a content URI from the app FileProvider.
- Incoming SMS_DELIVER_ACTION refreshes the visible thread without restarting the app.

### RCS-aware handoff and explicit fallback

Prevent broken RCS expectations by detecting when a conversation should use RCS, offering handoff to an installed RCS-capable client, and clearly falling back to SMS/MMS when native RCS sending is unavailable.

- **Answers complaint:** No RCS support

- **Screens:** ConversationList, ConversationThread, ComposeMessage

- **Estimated hours:** 36

**Implementation notes:** Create RcsAvailabilityRepository with three states: UNAVAILABLE, HANDOFF_AVAILABLE, and SMS_FALLBACK. Detect an installed RCS-capable client by checking PackageManager for package com.google.android.apps.messaging and for an activity that resolves Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:")) outside this app. On ConversationThread, if the thread has multiple recipients or the user taps the RCS banner, show an RCS handoff banner. The banner launches Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:${Uri.encode(addresses.joinToString(","))}")) with setPackage("com.google.android.apps.messaging") only when that package resolves; otherwise show SMS_FALLBACK copy and keep the local SMS/MMS composer enabled. Never silently downgrade a group message: before sending to more than one recipient, show a one-time confirmation dialog saying it will be sent as MMS/SMS unless opened in the RCS client.

**Acceptance criteria:**
- On a device with com.google.android.apps.messaging installed, a group thread shows an Open RCS client action.
- Tapping Open RCS client starts an ACTION_SENDTO smsto intent resolved to com.google.android.apps.messaging.
- On a device without a resolvable external RCS client, the banner text says RCS unavailable on this device and the send button remains SMS/MMS only.
- For a group message, the first local send attempt shows an explicit SMS/MMS fallback confirmation before calling SmsManager.
- The app never labels an SMS/MMS-only send path as RCS.

### No spoofed conversation ads

Keep all message lists and message threads free of ad rows, fake-contact promotions, and audio ads.

- **Answers complaint:** Ads disguised as contacts

- **Screens:** ConversationList, ConversationThread, ThemeEditor, Settings

- **Estimated hours:** 16

**Implementation notes:** Do not integrate any advertising SDK. Represent the free-tier promotion as a first-party Compose Card with testTag("static_upgrade_banner"), fixed text "Unlock full theming once for $4.99", and no audio/video/media player. Render this card only on Settings and ThemeEditor locked state; never include it in the ConversationList LazyColumn items produced from Telephony threads and never render it in ConversationThread. Use a sealed ConversationListItem type with only SmsThreadItem and EmptyStateItem; do not create an AdItem subtype.

**Acceptance criteria:**
- ConversationList contains zero nodes with testTag static_upgrade_banner.
- ConversationThread contains zero nodes with testTag static_upgrade_banner.
- Settings for a free user contains exactly one static_upgrade_banner card.
- The app has no code path that starts MediaPlayer, ExoPlayer, WebView, or an ad SDK for promotional content.
- The promotional card is visually labeled as an upgrade offer and is not sorted among contact threads.

### No third-party ad or data SDK bundle

Keep the dependency graph free of advertising, analytics, attribution, and data-harvesting SDKs that could trigger malware/security complaints.

- **Answers complaint:** Flagged as malware

- **Screens:** Settings

- **Estimated hours:** 12

**Implementation notes:** Add a Gradle task named verifyNoAdOrTrackerSdks that resolves debugRuntimeClasspath and releaseRuntimeClasspath and fails the build if any dependency group/name contains these lowercase tokens: play-services-ads, firebase-analytics, app-measurement, facebook, audience-network, applovin, ironsource, adjust, appsflyer, branch, flurry, unity-ads, vungle, chartboost. The allowed dependency set is AndroidX, KotlinX serialization, Coil, and Play Billing only. Expose a Security transparency row in Settings that states: "No third-party ad SDKs. No analytics SDKs. Messages stay on this device unless you export a backup."

**Acceptance criteria:**
- ./gradlew verifyNoAdOrTrackerSdks passes with the planned dependency list.
- Adding com.google.android.gms:play-services-ads to debugImplementation makes verifyNoAdOrTrackerSdks fail.
- Settings displays the exact no-SDK transparency statement.
- The release APK dependency tree contains no dependency whose artifact name includes ads, analytics, adjust, appsflyer, or audience-network.

### Full theming unlock

Provide color themes and per-thread backgrounds, with advanced theming unlocked by a one-time purchase.

- **Answers complaint:** baseline parity

- **Screens:** ThemeEditor, ConversationThread, Settings, Purchase

- **Estimated hours:** 52

**Implementation notes:** Store GlobalTheme and ThreadTheme in Room. Free users may view the ThemeEditor preview but saving custom colors or background images navigates to Purchase. Paid users can set primaryColorHex, outgoingBubbleHex, incomingBubbleHex, backgroundColorHex, and backgroundImageUri. Validate hex input with Regex("^#[0-9A-Fa-f]{6}$"). Use ActivityResultContracts.PickVisualMedia for background images so no media permission is required. Apply global theme from GlobalTheme id 1 at app composition root; apply ThreadTheme overrides inside ConversationThread by threadId.

**Acceptance criteria:**
- A free user changing a color and tapping Save is routed to Purchase instead of writing ThreadTheme.
- A paid user can save #2255AA as outgoingBubbleHex and the next ConversationThread render uses that color for outgoing bubbles.
- Invalid color text such as 2255AA or #XYZ123 shows validation_error and does not write Room.
- Selecting a background image stores its URI string in ThreadTheme.backgroundImageUri.
- GlobalTheme id 1 is created with #146C60 primary color on first run.

### Local backup and restore

Export and import categories, theme settings, and entitlement cache metadata to a local JSON file chosen by the user.

- **Answers complaint:** baseline parity

- **Screens:** BackupRestore, Settings

- **Estimated hours:** 40

**Implementation notes:** Use ACTION_CREATE_DOCUMENT with MIME application/json and default filename clearthread-backup.json for export. Serialize a BackupV1 JSON object with schemaVersion=1, exportedAtEpochMillis, categories, threadCategoryCrossRefs, globalTheme, threadThemes, and entitlementCache excluding raw purchase tokens. Use kotlinx.serialization Json { prettyPrint = true; ignoreUnknownKeys = true }. For import, use ACTION_OPEN_DOCUMENT, decode BackupV1, show an import_preview listing counts, then replace Category, ThreadCategoryCrossRef, GlobalTheme, and ThreadTheme tables in a single Room transaction. Do not import entitlementCache as proof of ownership; after restore call Play Billing queryPurchasesAsync to re-verify ownership.

**Acceptance criteria:**
- Export creates a JSON document with schemaVersion equal to 1.
- Exported JSON contains categories and themes but no raw Play Billing purchase token.
- Import preview displays the exact number of categories and thread themes found in the file.
- Confirming import replaces existing local categories and themes in one Room transaction.
- After import, Play Billing restore is triggered before any paid theming feature is enabled.

### Categories and folders

Let users create named categories/folders and assign existing SMS/MMS threads to them for filtered browsing.

- **Answers complaint:** baseline parity

- **Screens:** Categories, ConversationList

- **Estimated hours:** 36

**Implementation notes:** Implement CategoryDao with insert, updateNameColor, delete, observeAll ordered by sortOrder, and observeThreadIdsForCategory. Implement ThreadCategoryCrossRefDao with setCategoriesForThread(threadId, categoryIds) using delete existing refs for threadId then insert new refs in a transaction. ConversationList loads Telephony threads first, then filters the in-memory list by selected category thread IDs. Deleting a category cascades ThreadCategoryCrossRef rows via foreign key on categoryId. Use max category name length 32 and reject blank names.

**Acceptance criteria:**
- Creating category Family with color #146C60 inserts one Category row.
- Assigning threadId 42 to Family inserts one ThreadCategoryCrossRef row.
- Selecting Family on ConversationList shows threadId 42 and hides unassigned threads.
- Deleting Family removes its ThreadCategoryCrossRef rows.
- Blank category names and names longer than 32 visible characters show validation error and do not insert.

### One-time purchase restore

Sell and restore the $4.99 full-theming unlock without regressing paid status after reinstall.

- **Answers complaint:** Lifetime purchases regressing

- **Screens:** Purchase, ThemeEditor, Settings, BackupRestore

- **Estimated hours:** 16

**Implementation notes:** Use Play Billing product id clearthread_full_theming configured as an in-app one-time product priced at $4.99. On app start, Purchase screen open, and after backup import, call BillingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()). If a purchase for clearthread_full_theming has PurchaseState.PURCHASED, acknowledge it if not acknowledged, then upsert EntitlementCache(productId, true, sha256(purchaseToken), now). If query returns no owned purchase, set isOwned=false only after a successful BillingResult.OK response, never on network or service errors. Gate only full theming behind this entitlement.

**Acceptance criteria:**
- A successful purchase of clearthread_full_theming sets EntitlementCache.isOwned to true.
- Reinstall simulation with queryPurchasesAsync returning the owned product restores isOwned true without requiring another payment.
- A BillingResult service error does not change an existing true entitlement to false.
- A successful empty queryPurchasesAsync response sets isOwned false.
- Only ThemeEditor save actions are gated by the entitlement; SMS/MMS messaging remains usable for free users.

## Store listing

- **Title:** ClearThread SMS
- **Short description:** Clean SMS/MMS with no fake-contact ads and honest RCS fallback.
- **Category:** Communication
- **Keywords:** sms, mms, messenger, texting, no ads, rcs fallback, themes, backup, folders
- **Icon prompt:** Create a modern Android launcher icon for an app named ClearThread SMS: rounded square background in deep teal #146C60, centered white chat bubble with three clean horizontal message lines, no brand letters, no gradients beyond subtle Material-style depth, trustworthy and minimal, suitable for Play Store communication category.

**Long description:**

ClearThread SMS is built for people leaving messengers that put ads inside the conversation list. Your inbox stays your inbox: no fake-contact ads, no audio ads, and no third-party ad or analytics SDKs. Use it as a clean SMS/MMS messenger with folders, local backup and restore, and optional full theming. RCS availability is shown clearly: open an installed RCS-capable client when needed, or send as SMS/MMS only after an explicit fallback confirmation. Full theming is a one-time $4.99 unlock.

## Legal

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

## Test plan

### 1. Ads disguised as contacts (instrumented)

1. Seed the fake Telephony repository used by androidTest with two threads: threadId 1 address +15550100 body Hi and threadId 2 address +15550200 body Hello.
2. Set EntitlementCache.isOwned to false.
3. Launch ConversationList.
4. Find all nodes with testTag conversation_row and collect their displayed titles.
5. Search the tree for testTag static_upgrade_banner.

**Expected:** Exactly two conversation_row nodes are visible for the seeded threads, neither row contains upgrade/ad copy, and static_upgrade_banner is absent from ConversationList.

### 2. Ads disguised as contacts plus loud unmutable audio ads (instrumented)

1. Set EntitlementCache.isOwned to false.
2. Launch Settings.
3. Assert one node with testTag static_upgrade_banner exists.
4. Navigate to ConversationThread for threadId 1.
5. Search for nodes with testTag static_upgrade_banner.
6. Check the app process has not created a MediaPlayer through the test FakePromoMediaFactory, whose creation count starts at zero.

**Expected:** Settings shows one static upgrade banner, ConversationThread shows none, and FakePromoMediaFactory creation count remains zero.

### 3. Flagged as malware by bundled third-party SDK (unit)

1. Run the verifyNoAdOrTrackerSdks task against the normal debugRuntimeClasspath fixture.
2. Run the same verifier against a test fixture dependency list containing com.google.android.gms:play-services-ads:23.4.0.
3. Run the verifier against a fixture containing com.appsflyer:af-android-sdk:6.15.0.

**Expected:** The normal fixture passes; the play-services-ads and appsflyer fixtures both fail with an error naming the blocked dependency.

### 4. Lifetime purchases regressing after reinstall (unit)

1. Create EntitlementCache with isOwned false.
2. Configure FakeBillingClient.queryPurchasesAsync to return one PURCHASED and acknowledged purchase with product id clearthread_full_theming and token token-123.
3. Call PurchaseRepository.refreshEntitlement().
4. Delete and recreate the repository while preserving the fake billing response.
5. Call refreshEntitlement again.

**Expected:** EntitlementCache.isOwned is true after both refresh calls and purchaseTokenHash equals SHA-256 of token-123, proving restore does not require a second purchase.

### 5. Lifetime purchase should not regress on billing service error (unit)

1. Seed EntitlementCache for clearthread_full_theming with isOwned true.
2. Configure FakeBillingClient.queryPurchasesAsync to return BillingResult with SERVICE_UNAVAILABLE and no purchases.
3. Call PurchaseRepository.refreshEntitlement().
4. Read EntitlementCache from Room.

**Expected:** EntitlementCache.isOwned remains true because only a successful BillingResult.OK empty response may clear ownership.

### 6. No RCS support (manual)

1. Install the app on a test phone that also has Google Messages installed and configured.
2. Make ClearThread the default SMS app.
3. Open or create a group thread with at least two recipient phone numbers.
4. Observe the banner at the top of ConversationThread.
5. Tap Open RCS client.

**Expected:** ConversationThread shows an RCS handoff banner, and tapping it opens Google Messages via an ACTION_SENDTO smsto intent instead of silently sending the message as SMS/MMS.

### 7. No RCS support fallback clarity (manual)

1. Use a test device or emulator without Google Messages or any external smsto handler other than ClearThread.
2. Open a group thread with at least two recipient phone numbers.
3. Type 'test group fallback' and tap Send.
4. Read the confirmation dialog before accepting.

**Expected:** The UI says RCS is unavailable on this device and requires explicit confirmation before sending as SMS/MMS.

### 8. baseline parity local backup/restore (instrumented)

1. Insert Category id 1 name Family color #146C60.
2. Insert ThreadTheme for threadId 7 with outgoingBubbleHex #2255AA.
3. Run BackupExporter.exportToString().
4. Clear the Room database.
5. Run BackupImporter.preview() on the exported JSON.
6. Confirm import and query Room.

**Expected:** Preview reports one category and one thread theme, and after import Room contains Family and threadId 7 with outgoingBubbleHex #2255AA.

### 9. baseline parity categories/folders (instrumented)

1. Seed fake Telephony threads 42 and 43.
2. Create Category Family.
3. Assign threadId 42 to Family.
4. Launch ConversationList.
5. Tap the Family category chip.

**Expected:** Only threadId 42 is displayed after selecting the Family chip; threadId 43 is hidden.

### 10. baseline parity theming engine (instrumented)

1. Set EntitlementCache.isOwned true.
2. Open ThemeEditor for threadId 42.
3. Enter #2255AA in outgoing bubble color.
4. Tap Save.
5. Navigate to ConversationThread for threadId 42.

**Expected:** Outgoing message bubbles in threadId 42 render with color #2255AA.

## Build instructions

```sh
set -e
./gradlew clean
./gradlew verifyNoAdOrTrackerSdks
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
if [ ! -f release.keystore ]; then keytool -genkeypair -v -keystore release.keystore -storepass ClearThreadStorePass123! -alias clearthread-release -keypass ClearThreadKeyPass123! -keyalg RSA -keysize 4096 -validity 10000 -dname "CN=ClearThread Messenger,O=Solo Builder,L=Internet,ST=NA,C=US"; fi
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/release.keystore -Pandroid.injected.signing.store.password=ClearThreadStorePass123! -Pandroid.injected.signing.key.alias=clearthread-release -Pandroid.injected.signing.key.password=ClearThreadKeyPass123!
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
