# ClearLoop SMS — build spec

A default SMS/MMS messenger that prioritizes reliable group threads, correct notifications, adaptive spam scoring, and an absolute no-ads guarantee for paid subscribers.

## 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:** Messenger SMS - Text Messages
- **Package id:** `com.link.messages.sms`
- **Google Play:** https://play.google.com/store/apps/details?id=com.link.messages.sms
- **appy.fyi report:** https://appy.fyi/report/com.link.messages.sms
- **Category:** Communication

## Overview

- **Working name:** ClearLoop SMS (trademark cleared: no)
- **Package id:** `fyi.appy.clearloopsms`
- **Min / target SDK:** 23 / 35
- **Backend:** none
- **Estimated build time:** 7 weeks
- **Pricing:** subscription, $2.99 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `INTERNET`, `POST_NOTIFICATIONS`

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

- RCS chat features, typing indicators, read receipts, or carrier/Jibe integration are out of scope for v1.
- Cloud sync, web messaging, account backup, and multi-device messaging are out of scope for v1.
- Community-maintained spam blocklists are out of scope; v1 uses on-device scoring and local user block decisions only.
- Telemarketer voice-call blocking is out of scope for v1 because the report centers the app on becoming a default SMS handler.
- End-to-end encryption beyond carrier SMS/MMS transport is out of scope for v1.
- Custom sticker stores, chat bots, message reactions, and other social-messenger extras are out of scope until core SMS/MMS reliability is proven.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Material 3 Compose UI components | `androidx.compose.material3:material3:1.3.0` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware ViewModel state for Compose | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware collection of Kotlin Flow in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Room local database runtime | `androidx.room:room-runtime:2.6.1` |
| Room Kotlin Flow and coroutine extensions | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor via KSP | `androidx.room:room-compiler:2.6.1` |
| Kotlin coroutine support on Android | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Small persisted preferences for default-handler, theme, spam threshold, and premium cache | `androidx.datastore:datastore-preferences:1.1.1` |
| Monthly subscription entitlement management | `com.revenuecat.purchases:purchases:8.10.4` |
| Free-tier ad display that can be completely disabled for paid subscribers | `com.google.android.gms:play-services-ads:23.5.0` |
| Phone number normalization for thread grouping and spam scoring | `com.googlecode.libphonenumber:libphonenumber:8.13.47` |
| On-device TensorFlow Lite spam scoring model runtime | `org.tensorflow:tensorflow-lite:2.16.1` |
| Periodic local reconciliation of SMS provider rows, Room mirror, and notification state | `androidx.work:work-runtime-ktx:2.9.1` |
| AndroidX core utilities and backward-compatible notification helpers | `androidx.core:core-ktx:1.13.1` |

## Design system

- **Primary color:** `#155E63`
- **Background color:** `#F7FAF9`
- **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 explicitly enables it in Settings; otherwise use #155E63 as primary in light mode, #80D8D0 as primary in dark mode, #F7FAF9 light background, and #101414 dark background. Conversation bubbles are #DCF4EF for outgoing and #FFFFFF light / #1D2525 dark for incoming. Paid-status and spam-warning UI must use text labels in addition to color.

## Screens

### Default Setup
- **Route:** `setup`
- **Purpose:** Guide the user through setting the app as the default SMS handler and granting notification permission before the inbox is used.
- **Reached via:** app launch when app is not default SMS handler; tap Default SMS status in Settings
- **Key UI elements:** App value proposition text focused on reliable SMS/MMS and no ads for subscribers; Current default SMS status row; Button: Set as default SMS app; Notification permission status row; Button: Enable notifications; Continue button disabled until default SMS role is granted
- **States:** loading current role and permission status, not default sms handler, default sms handler but notifications not granted, ready to continue, error launching default SMS role request

### Inbox
- **Route:** `inbox`
- **Purpose:** Show recent one-to-one and group message threads in stable chronological order.
- **Reached via:** app launch when default SMS role is already granted; tap Back from Conversation; tap Back from Spam Review; tap Back from Settings
- **Key UI elements:** Top app bar with app title and Settings icon; Search field for local thread filtering; LazyColumn of thread rows with title, participant count for groups, last message preview, timestamp, unread badge, and spam badge when applicable; Floating action button for New Message; Free-tier ad slot below the top app bar, hidden whenever premium entitlement is active
- **States:** loading threads from Telephony provider and Room mirror, empty with prompt to start a conversation, populated, filtered empty, error reading local message provider, not default sms handler

### Conversation
- **Route:** `conversation/{threadId}`
- **Purpose:** Display and send SMS/MMS messages for a single one-to-one or group thread without scroll jumps or flashing.
- **Reached via:** tap a thread row on Inbox; tap a notification for a received message; tap existing thread result from New Message
- **Key UI elements:** Top app bar with conversation title and group participant subtitle; LazyColumn message list keyed by stable message id; Date separators; Incoming and outgoing message bubbles; Per-message sending, sent, failed, and delivered status text; Attachment preview row for MMS images; Composer text field; Attach image button; Send button; Retry failed message action; Spam warning banner when the thread is classified as spam
- **States:** loading messages, empty thread, populated, sending message, send failed with retry available, mms attachment preparing, error loading thread, not default sms handler

### New Message
- **Route:** `compose`
- **Purpose:** Create a new one-to-one or group SMS/MMS thread and route it to the correct conversation.
- **Reached via:** tap New Message floating action button on Inbox
- **Key UI elements:** Recipient entry field accepting phone numbers; Recipient chips; Validation text for invalid numbers; Message composer text field; Attach image button; Send button; Existing matching thread suggestion list
- **States:** empty recipients, recipients entered but invalid, ready to send, sending, send failed, matched existing thread

### Spam Review
- **Route:** `spam`
- **Purpose:** Let users review locally filtered spam threads, restore false positives, and block or unblock rotating-number patterns.
- **Reached via:** tap Spam Review in Settings; tap spam warning banner on Inbox if visible
- **Key UI elements:** List of spam-classified threads; Spam score and reason chips for each thread; Action: Mark not spam; Action: Block sender; Action: Delete local thread mirror entry; Spam threshold slider
- **States:** loading spam threads, empty with explanation that spam will appear here, populated, error reading spam classifications

### Settings
- **Route:** `settings`
- **Purpose:** Control premium status, ad-free guarantee, theme, spam settings, and default-handler status.
- **Reached via:** tap Settings icon on Inbox
- **Key UI elements:** Premium entitlement card showing Free or Ad-free active; Subscribe button for $2.99/month; Restore purchases button; Ad-free guarantee text stating no ad requests are made while subscribed; Default SMS handler status row; Spam Review navigation row; Theme selector: System, Light, Dark; Dynamic color toggle; Notification diagnostics row showing pending notification count
- **States:** loading settings and entitlement, free tier, premium active, purchase in progress, purchase failed, restore failed, offline entitlement cache visible

## Data model

### ThreadEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key; matches Telephony.Threads._ID when available |
| canonicalKey | `String` | sorted E.164 participant numbers joined with '\|'; unique index |
| displayTitle | `String` | participant-derived title for inbox row |
| isGroup | `Boolean` | true when participantNumbers has more than one recipient besides the local subscription number |
| participantNumbersJson | `String` | JSON array of normalized E.164 numbers |
| lastMessageAtEpochMillis | `Long` | used for descending inbox sort |
| lastMessagePreview | `String` | truncated to 160 characters before storage |
| unreadCount | `Int` | recomputed from MessageEntity read=false |
| spamScore | `Float` | 0.0 to 1.0 from on-device scorer |
| isSpam | `Boolean` | true when spamScore is at or above the current threshold or userMarkedSpam is true |
| userMarkedSpam | `Boolean` | manual override |
| userMarkedNotSpam | `Boolean` | manual false-positive override |

### MessageEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key; local generated id for outgoing pending messages, replaced with provider id after insert |
| providerMessageId | `Long?` | nullable Telephony provider row id |
| threadId | `Long` | foreign key to ThreadEntity.id |
| senderNumber | `String` | normalized E.164; local number for outgoing |
| body | `String` | empty string for attachment-only MMS |
| timestampEpochMillis | `Long` | provider timestamp or local send enqueue timestamp |
| direction | `String` | one of INCOMING, OUTGOING |
| transport | `String` | one of SMS, MMS |
| status | `String` | one of RECEIVED, SENDING, SENT, DELIVERED, FAILED |
| read | `Boolean` | false for unread incoming messages until conversation is opened |
| errorCode | `Int?` | nullable SmsManager result code for failed outgoing messages |

### AttachmentEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| messageId | `Long` | foreign key to MessageEntity.id |
| contentUri | `String` | persistable local content URI string |
| mimeType | `String` | for v1 primarily image/jpeg, image/png, image/webp |
| byteSize | `Long` | used to reject oversized MMS attachments before send |

### BlockedPatternEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| patternType | `String` | one of EXACT_NUMBER, AREA_PREFIX, MESSAGE_TOKEN |
| patternValue | `String` | normalized number, E.164 prefix, or lowercase token |
| createdAtEpochMillis | `Long` |  |

### SpamDecisionEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| messageId | `Long` | foreign key to MessageEntity.id |
| score | `Float` | 0.0 to 1.0 |
| reasonsJson | `String` | JSON array such as ['rotating_prefix','url_present','unknown_sender'] |
| createdAtEpochMillis | `Long` |  |

### PremiumEntitlementEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 |
| isPremium | `Boolean` | true only when RevenueCat entitlement 'ad_free' is active |
| expiresAtEpochMillis | `Long?` | nullable for unknown expiration |
| lastCheckedAtEpochMillis | `Long` | used for offline cache display |

## Features

### Default SMS handler compliance

Requests and verifies default SMS app role before enabling inbox, send, receive, and notification behavior.

- **Answers complaint:** baseline parity

- **Screens:** Default Setup, Inbox, Settings

- **Estimated hours:** 22

**Implementation notes:** On Android 10+ call RoleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) from the Default Setup screen and verify RoleManager.isRoleHeld(RoleManager.ROLE_SMS) after the result. On Android 9 and below, launch Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT with Telephony.Sms.Intents.EXTRA_PACKAGE_NAME set to the app package, then verify Telephony.Sms.getDefaultSmsPackage(context) equals the app package. Manifest must include the SMS-role components required by Android default SMS apps: BroadcastReceiver for SMS_DELIVER_ACTION, BroadcastReceiver for WAP_PUSH_DELIVER_ACTION with MIME type application/vnd.wap.mms-message, Activity handling ACTION_SENDTO for sms/smsto/mms/mmsto schemes, and Service for ACTION_RESPOND_VIA_MESSAGE. Keep the Inbox in 'not default sms handler' state until verification passes.

**Acceptance criteria:**
- When the app is not default SMS handler, app launch navigates to Default Setup instead of Inbox.
- Tapping Set as default SMS app launches the platform default-SMS role request intent.
- After the role is granted, Default Setup shows ready to continue and Continue opens Inbox.
- If the role is revoked from system settings while the app is open, Inbox switches to the not default sms handler state within one resume cycle.

### Stable SMS/MMS inbox sync

Mirrors recent SMS/MMS provider rows into local Room entities and keeps the inbox sorted without losing recent threads.

- **Answers complaint:** Core messaging bugs

- **Screens:** Inbox, Conversation

- **Estimated hours:** 42

**Implementation notes:** Create a MessageRepository that queries Telephony.Sms.CONTENT_URI and Telephony.Mms.CONTENT_URI on app start, after SMS/MMS deliver broadcasts, and from a WorkManager reconciliation job every 6 hours. Normalize all phone numbers with libphonenumber using the device SIM country as region fallback. Compute ThreadEntity.canonicalKey from the sorted normalized participants, not from the provider thread id alone, so group and one-to-one threads cannot overwrite each other. Upsert MessageEntity by providerMessageId plus transport. Recompute ThreadEntity.lastMessageAtEpochMillis and unreadCount in a single Room transaction after every sync batch. Limit the initial query to the most recent 500 messages, then page older messages per conversation when opened.

**Acceptance criteria:**
- A newly received SMS appears as the first Inbox row after the SMS deliver receiver processes it.
- A one-to-one thread and a group thread containing the same primary contact produce different ThreadEntity.canonicalKey values.
- Restarting the app after receiving messages does not remove recent threads that were visible before restart.
- Inbox rows are sorted by lastMessageAtEpochMillis descending after every sync batch.

### Reliable group messaging

Creates, displays, and sends group SMS/MMS threads without collapsing them into individual conversations.

- **Answers complaint:** Core messaging bugs

- **Screens:** Inbox, Conversation, New Message

- **Estimated hours:** 44

**Implementation notes:** Treat any conversation with more than one remote participant as isGroup=true and force outgoing sends for group conversations through MMS using SmsManager.sendMultimediaMessage, even for text-only group messages, so replies stay in one group thread. For incoming MMS, parse participants from Telephony.Mms.Addr rows where address is not the local number, normalize them, and build the same sorted canonicalKey used by outgoing group sends. In New Message, when two or more recipients are entered, look up an existing ThreadEntity by canonicalKey before creating a new one. Conversation title should be the comma-separated recipient list truncated after three names/numbers with '+N' suffix.

**Acceptance criteria:**
- Sending a message to two recipients creates one Inbox row marked as a group, not two one-to-one rows.
- An incoming group MMS reply resolves to the same ThreadEntity as the outgoing group message when the normalized participant set matches.
- Opening a group thread shows all messages for that participant set in timestamp order.
- Adding the same recipients in a different order from New Message opens the existing group thread instead of creating a duplicate.

### Conversation rendering without flashing or scroll runaway

Renders long conversations with stable keys and controlled auto-scroll so the display does not flash or uncontrollably scroll.

- **Answers complaint:** Core messaging bugs

- **Screens:** Conversation

- **Estimated hours:** 18

**Implementation notes:** Use LazyColumn with key = MessageEntity.id and contentType = transport/status bucket. Store LazyListState per threadId in a ViewModel map. Only auto-scroll to bottom when the user is already within 3 visible items of the bottom or when the local user sends a message; otherwise show a 'New messages' chip. Use derivedStateOf for bottom detection and collect message Flow with distinctUntilChanged on message id/status/body tuples. Do not call scrollToItem from composition; call animateScrollToItem only inside LaunchedEffect keyed by a new local outgoing id or explicit chip click.

**Acceptance criteria:**
- Opening a 300-message thread renders without repeated navigation, recomposition-triggered scroll loops, or visible flashing.
- Receiving an incoming message while scrolled near the top does not move the scroll position.
- Receiving an incoming message while already at the bottom scrolls to the new message once.
- Sending a local message scrolls to the sent message once and keeps the composer focused.

### SMS and MMS send loop with explicit delivery state

Sends text and image messages and records sending, sent, delivered, and failed states per message.

- **Answers complaint:** Core messaging bugs

- **Screens:** Conversation, New Message

- **Estimated hours:** 36

**Implementation notes:** For one-to-one text under SMS length limits, use SmsManager.sendTextMessage with PendingIntent actions ACTION_SMS_SENT and ACTION_SMS_DELIVERED containing the local message id. For multipart text, use SmsManager.divideMessage and sendMultipartTextMessage with per-part sent intents; mark the message SENT only after all parts return Activity.RESULT_OK. For MMS image or group messages, create a temporary content URI for the PDU/attachment package and call SmsManager.sendMultimediaMessage with a sent PendingIntent; mark FAILED with the platform result code on non-OK. Insert outgoing MessageEntity as SENDING before calling SmsManager; update status only from broadcast results so the UI and notifications share one source of truth.

**Acceptance criteria:**
- Immediately after tapping Send, the outgoing bubble shows SENDING.
- When the sent PendingIntent returns Activity.RESULT_OK, the bubble changes to SENT.
- When the sent PendingIntent returns a non-OK result, the bubble changes to FAILED and displays Retry.
- Retry on a failed message reuses the same body and recipients and creates a new send attempt without duplicating the failed bubble.

### Notification correctness

Creates incoming-message notifications and clears or updates them when messages are read or outgoing sends finish.

- **Answers complaint:** Core messaging bugs

- **Screens:** Conversation, Settings

- **Estimated hours:** 24

**Implementation notes:** Use NotificationManagerCompat with one notification per ThreadEntity.id and notification id = threadId modulo Int.MAX_VALUE. Incoming SMS/MMS receiver inserts MessageEntity(read=false), recomputes unreadCount, and posts or updates a MessagingStyle notification. Opening Conversation marks all messages for that thread read in Room and cancels that thread notification in the same coroutine after the Room transaction succeeds. Outgoing SENDING notifications are not persistent; failed outgoing messages update the conversation bubble but do not create a forever notification. Run a WorkManager reconciliation job every 6 hours that cancels notifications for threads whose unreadCount is zero.

**Acceptance criteria:**
- Receiving an incoming message while outside the conversation posts exactly one notification for that thread.
- Opening the conversation marks the incoming message read and cancels that thread notification.
- A failed outgoing message never creates an ongoing notification that remains after app restart.
- The Settings notification diagnostics row shows zero pending notifications after all conversations are read.

### Adaptive on-device spam scoring

Scores incoming messages locally for spam signals so rotating numbers can be filtered without relying on a static blocklist.

- **Answers complaint:** Spam and telemarketer exposure

- **Screens:** Inbox, Conversation, Spam Review, Settings

- **Estimated hours:** 40

**Implementation notes:** Bundle a TensorFlow Lite binary classifier asset at app/src/main/assets/spam_sms_model.tflite that accepts fixed numeric features and outputs a spam probability from 0.0 to 1.0. Build the feature vector on-device from: sender seen before flag, sender exact number blocked flag, E.164 area-prefix frequency in the last 7 days, message contains URL flag, message contains phone number flag, message length bucket, uppercase ratio bucket, non-contact sender flag, and token hashes for the first 20 normalized lowercase tokens. Combine model probability with local overrides: exact blocked number returns 1.0, userMarkedNotSpam returns 0.0. Default threshold is 0.80 and is adjustable on Spam Review. Messages above threshold remain in Room and provider storage but are hidden from Inbox and shown in Spam Review with reason chips derived from the highest-contributing deterministic features.

**Acceptance criteria:**
- An incoming message from an exact blocked number is classified with spamScore 1.0.
- A message manually marked not spam remains visible in Inbox even if the model score is above the threshold.
- A new sender with rotating same-area prefix, URL present, and unknown-sender features appears in Spam Review when score is at least 0.80.
- Changing the threshold in Spam Review recomputes isSpam for existing SpamDecisionEntity rows within one second for a 500-message local dataset.

### Absolute ad-free premium gate

Uses a $2.99/month subscription entitlement to guarantee that paid users never receive ad requests or see ad containers.

- **Answers complaint:** Ads survive the paid tier

- **Screens:** Inbox, Settings, Conversation, New Message, Default Setup, Spam Review

- **Estimated hours:** 26

**Implementation notes:** Configure RevenueCat offering with monthly package product id clearloop_ad_free_monthly and entitlement id ad_free. On app start and Settings resume, call Purchases.sharedInstance.getCustomerInfo and persist PremiumEntitlementEntity. Wrap all ad UI in a single AdGate composable that reads isPremium from a StateFlow. If isPremium is true, AdGate must not compose AndroidView ad containers and must not call MobileAds.initialize or loadAd. If isPremium is false, allow a single banner slot on Inbox only; no ads are allowed in Conversation, New Message, MMS attachment flow, Default Setup, or Spam Review. Add a unit-testable AdPolicy class with function mayRequestAd(screenName, isPremium) returning false for every screen when premium is true.

**Acceptance criteria:**
- With PremiumEntitlementEntity.isPremium=true, mayRequestAd returns false for Inbox, Conversation, New Message, Default Setup, Spam Review, and Settings.
- With premium active, MobileAds.initialize is not called during app launch.
- With premium active, no ad composable is present in Conversation while sending text or MMS.
- After a successful subscription purchase, the Inbox ad slot is removed without requiring app restart.

### Premium purchase and restore flow

Lets users subscribe to and restore the $2.99/month ad-free tier from Settings.

- **Answers complaint:** Ads survive the paid tier

- **Screens:** Settings, Inbox

- **Estimated hours:** 18

**Implementation notes:** In Settings, fetch RevenueCat offerings and display the monthly package whose store product id is clearloop_ad_free_monthly with the displayed price from RevenueCat. On Subscribe tap, call Purchases.sharedInstance.purchaseWith with the selected package and update PremiumEntitlementEntity from the returned CustomerInfo. On Restore purchases tap, call restorePurchases and update the same entity. If RevenueCat is unreachable, keep the last known entitlement for UI gating and show 'Last checked' timestamp; do not show ads to a locally cached premium user just because the network check failed.

**Acceptance criteria:**
- Settings shows a Subscribe button when the ad_free entitlement is absent.
- After a successful purchase result containing ad_free active, Settings shows Premium active.
- Restore purchases updates local premium state when RevenueCat returns ad_free active.
- If the app is offline and the cached entitlement is premium, Inbox remains ad-free and Settings shows the last checked timestamp.

### Theme and polish settings

Provides basic light, dark, system, and optional dynamic-color appearance controls without affecting messaging reliability.

- **Answers complaint:** baseline parity

- **Screens:** Settings, Inbox, Conversation, New Message, Spam Review, Default Setup

- **Estimated hours:** 10

**Implementation notes:** Persist themeMode as one of SYSTEM, LIGHT, DARK in DataStore Preferences and expose it as StateFlow to the root composable. Persist dynamicColorEnabled as Boolean, default false. Apply MaterialTheme colorScheme from fixed colors unless dynamicColorEnabled is true and Build.VERSION.SDK_INT >= 31. The setting must be independent from premium state so theme changes never trigger billing or ad refresh logic.

**Acceptance criteria:**
- Selecting Dark in Settings immediately applies dark background to Inbox and Conversation.
- Selecting System follows AppCompatDelegate/system night mode on next recomposition.
- Dynamic color toggle is disabled or hidden below Android 12.
- Changing theme does not call RevenueCat purchase APIs or MobileAds load APIs.

## Store listing

- **Title:** ClearLoop SMS
- **Short description:** Reliable SMS/MMS with group threads, spam filtering, and true ad-free premium.
- **Category:** Communication
- **Keywords:** sms, mms, messenger, text messaging, group sms, spam filter, ad free sms, default sms app
- **Icon prompt:** Create a clean Android launcher icon for an SMS messaging app named ClearLoop SMS: rounded square background in deep teal #155E63, centered white outlined chat bubble with three short text lines, subtle loop/circular motion accent around the bubble, flat vector style, high contrast, no words, no phone handset, no gradients, suitable at small sizes.

**Long description:**

ClearLoop SMS is a default SMS/MMS messenger built around the basics that matter: recent threads stay visible, group conversations stay grouped, notifications clear when messages are read, and spam is scored locally on your device.

The optional $2.99/month premium tier is simple: when ad-free is active, the app does not request ads and does not show ad spaces. No ads mid-conversation. No ads during multimedia sending. No exceptions.

Core v1 features:
• Default SMS/MMS handler flow
• Stable inbox sync for recent conversations
• Reliable group thread handling
• SMS and MMS sending with clear failed-message states
• Correct per-thread notifications
• Local spam review for suspicious rotating-number messages
• Light, dark, system, and optional dynamic-color themes

ClearLoop SMS does not provide RCS, cloud sync, web messaging, or multi-device backup in v1. The focus is dependable carrier SMS/MMS.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://clearloop-sms.example.com/privacy (privacy claims verified: no)
- **Data collected:** SMS and MMS message content stored locally on the user's device for inbox and conversation display; Phone numbers stored locally on the user's device for thread grouping and spam scoring; Local spam classification scores and user spam/not-spam decisions stored on the user's device; Subscription purchase status and anonymous app user identifier processed through RevenueCat for the ad-free entitlement

## Test plan

### 1. Ads survive the paid tier (unit)

1. Instantiate AdPolicy.
2. For each screen name Inbox, Conversation, NewMessage, DefaultSetup, SpamReview, Settings, call mayRequestAd(screenName, true).
3. Call mayRequestAd("Inbox", false).
4. Call mayRequestAd("Conversation", false).

**Expected:** All premium calls return false; free-tier Inbox returns true; free-tier Conversation returns false.

### 2. Ads survive the paid tier (instrumented)

1. Seed Room with PremiumEntitlementEntity(id=1, isPremium=true, expiresAtEpochMillis in the future, lastCheckedAtEpochMillis=now).
2. Launch MainActivity directly to Inbox with default-SMS state mocked as granted.
3. Wait for the Inbox loaded state.
4. Inspect the Compose tree for nodes tagged AdBanner and UpgradeAdContainer.

**Expected:** No node tagged AdBanner or UpgradeAdContainer exists, and the Inbox content list is still visible.

### 3. Missing recent threads (unit)

1. Create three fake provider messages with timestamps 1000, 3000, and 2000 milliseconds.
2. Run the repository sync mapper into an in-memory Room database.
3. Query ThreadEntity ordered by lastMessageAtEpochMillis descending.

**Expected:** The thread containing timestamp 3000 is first, timestamp 2000 second, timestamp 1000 third, with no synced provider message missing.

### 4. Group messages collapsing into individual ones or disappearing entirely (unit)

1. Normalize participant sets ['+15550100001', '+15550100002'] and ['+15550100002', '+15550100001'].
2. Build canonicalKey for both sets.
3. Build canonicalKey for one-to-one set ['+15550100001'].
4. Insert outgoing group thread and incoming group reply using the two differently ordered group keys.

**Expected:** The two group keys are identical to each other, different from the one-to-one key, and both group messages resolve to one ThreadEntity with isGroup=true.

### 5. Screens flashing/scrolling uncontrollably (instrumented)

1. Seed Room with one ThreadEntity and 300 MessageEntity rows.
2. Launch Conversation for that thread.
3. Scroll to message index 40.
4. Insert a new incoming MessageEntity at the bottom through the DAO.
5. Wait 500 milliseconds.
6. Read the LazyColumn first visible item index from the test tag ConversationList.

**Expected:** The first visible item index remains within 2 positions of 40 and no automatic scroll to the last item occurs.

### 6. Notifications stuck on messages that already sent (instrumented)

1. Seed Room with one outgoing MessageEntity(status=SENDING, read=true).
2. Send a fake ACTION_SMS_SENT broadcast with Activity.RESULT_CANCELED and the local message id.
3. Open Conversation for that thread.
4. Run the notification reconciliation worker.
5. Query NotificationDiagnosticsRepository for pending notification count.

**Expected:** The message status is FAILED in Room, no ongoing notification exists for the failed outgoing message, and pending notification count is 0.

### 7. Spam and telemarketer exposure from rotating numbers (unit)

1. Create local history with five spam-marked numbers sharing E.164 prefix +1555666 during the last 7 days.
2. Create an incoming message from +15556669999 containing a URL and from a non-contact sender.
3. Run SpamScorer.score with threshold 0.80.
4. Persist the resulting SpamDecisionEntity and update ThreadEntity.isSpam.

**Expected:** The resulting score is at least 0.80, reasons include rotating_prefix and url_present, and ThreadEntity.isSpam is true.

### 8. Baseline parity default SMS activation and SMS send (manual)

1. Install the release build on a physical Android phone with an active SIM.
2. Launch the app.
3. Tap Set as default SMS app and accept the platform role prompt.
4. From Inbox tap New Message.
5. Enter a second phone's number, type 'ClearLoop test SMS', and tap Send.
6. Wait until the second phone receives the message.

**Expected:** The app becomes the default SMS handler, the outgoing bubble changes from SENDING to SENT, and the second phone receives exactly one SMS with the typed text.

### 9. Baseline parity group messaging reliability (manual)

1. Use a physical phone with the app set as default SMS handler.
2. From New Message enter two recipient phone numbers.
3. Send 'ClearLoop group test'.
4. Have one recipient reply to the group thread.
5. Return to Inbox.

**Expected:** Inbox shows one group thread with a participant-count indicator; the outgoing message and recipient reply both appear in the same Conversation.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release.keystore -storepass changeit -keypass changeit -alias clearloop -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ClearLoop SMS,O=ClearLoop,C=US"
ANDROID_KEYSTORE_PATH=$PWD/release.keystore ANDROID_KEYSTORE_PASSWORD=changeit ANDROID_KEY_ALIAS=clearloop ANDROID_KEY_PASSWORD=changeit ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
