# KindCue — build spec

A safety-aware affirmations app with upfront $2.99/month pricing, clean content opt-outs, self-serve cancellation/refund paths, and synced favorites/settings.

## 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:** I am - Daily affirmations
- **Package id:** `com.hrd.iam`
- **Google Play:** https://play.google.com/store/apps/details?id=com.hrd.iam
- **appy.fyi report:** https://appy.fyi/report/com.hrd.iam
- **Category:** Health & Fitness

## Overview

- **Working name:** KindCue (trademark cleared: no)
- **Package id:** `fyi.appy.kindcue`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 4 weeks
- **Pricing:** subscription, $2.99 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `INTERNET`, `POST_NOTIFICATIONS`

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

- No free trial or annual plan in v1, because the opportunity is specifically about avoiding silent trial-to-annual conversion.
- No therapy, diagnosis, crisis counseling, or emergency intervention claims; the app only filters and presents affirmations more safely.
- No social feed, community posting, coaching marketplace, or creator marketplace.
- No in-app email-only support queue; v1 uses self-serve billing/refund links and local help text.
- No runtime LLM generation in v1; any AI-assisted content drafting/tagging happens before release and checked content ships as bundled, reviewed assets.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose Material 3 UI components | `androidx.compose.material3:material3:1.3.0` |
| Activity integration for Jetpack Compose | `androidx.activity:activity-compose:1.9.3` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware Compose ViewModels | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Android core Kotlin extensions | `androidx.core:core-ktx:1.13.1` |
| Local persistence for affirmations, favorites, settings, entitlement cache, and delivery logs | `androidx.room:room-runtime:2.6.1` |
| Room coroutine and Flow APIs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor via KSP | `androidx.room:room-compiler:2.6.1` |
| Coroutine support for repositories, Room, Firebase wrappers, and scheduling code | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Decode bundled JSON affirmation seed files into Kotlin data classes | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Periodic daily affirmation notification scheduling | `androidx.work:work-runtime-ktx:2.9.1` |
| Home screen affirmation widget | `androidx.glance:glance-appwidget:1.1.1` |
| Firebase email/password authentication for cross-device sync | `com.google.firebase:firebase-auth:23.1.0` |
| Firestore storage for synced favorites and settings | `com.google.firebase:firebase-firestore:25.1.1` |
| RevenueCat subscription entitlement and renewal-date access for the $2.99/month plan | `com.revenuecat.purchases:purchases:8.10.0` |

## Design system

- **Primary color:** `#2F6F5E`
- **Background color:** `#FFF9F1`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `heart` (regular weight)
- **Theme notes:** Use a calm warm light theme by default with #FFF9F1 background and #2F6F5E primary. Dark theme uses Material 3 dynamic dark surfaces when system dark mode is enabled, while keeping primary derived from #8CCDBA. Affirmation cards use rounded 24dp corners, 16dp internal padding, and no photographic imagery. Avoid religious symbols in default visuals.

## Screens

### Onboarding
- **Route:** `onboarding`
- **Purpose:** Collect account choice, disclosed sensitive-context filters, content category opt-outs, tone preference, and notification preference before the first affirmation is shown.
- **Reached via:** app launch when no local UserPreference exists; tap Edit onboarding answers from Settings
- **Key UI elements:** Plain-language intro stating the app is not therapy or crisis support; Email/password sign-in or create-account section with skip option marked local-only; Sensitive context multi-select chips: grief, trauma, assault, none of these; Content category opt-out chips including religious/spiritual content; Tone selector: gentle, direct, hopeful; Daily notification time picker; Continue button that saves UserPreference locally and syncs if signed in
- **States:** initial, saving, auth_error, validation_error, saved

### Paywall
- **Route:** `paywall`
- **Purpose:** Present the single upfront subscription offer with no trial and no annual conversion.
- **Reached via:** tap Subscribe from Home; tap Subscription from Settings; automatic display after onboarding if entitlement is inactive
- **Key UI elements:** Price text: $2.99/month; Statement: No free trial. No annual plan. Cancel any time in Google Play.; RevenueCat purchase button for monthly_premium; Restore purchases button; Link to Manage subscription and refund help; Entitlement status banner
- **States:** loading_products, product_available, purchase_in_progress, purchase_error, restored, already_subscribed

### Home
- **Route:** `home`
- **Purpose:** Show the current safe affirmation feed and allow saving favorites, refreshing, and opening filters.
- **Reached via:** app launch after onboarding; tap Home from bottom navigation; tap notification; tap widget
- **Key UI elements:** Current affirmation card; Why this fits link showing active filters and tags; Favorite toggle; Next affirmation button; Filter/settings shortcut; Subscribe banner if entitlement inactive
- **States:** loading, empty_after_filters, error, populated_unsubscribed, populated_subscribed

### Preferences
- **Route:** `preferences`
- **Purpose:** Let users cleanly opt out of religious/spiritual content and update sensitive-context safeguards at any time.
- **Reached via:** tap Filters from Home; tap Content filters from Settings; tap Edit onboarding answers from Onboarding completion screen
- **Key UI elements:** Sensitive context multi-select chips; Content category opt-out chips; Tone selector; Preview count of available affirmations after filters; Save button; Warning text explaining that crisis contexts suppress risky generic lines
- **States:** loading, loaded, saving, save_error, saved

### Favorites
- **Route:** `favorites`
- **Purpose:** List saved affirmations and show sync status for favorites.
- **Reached via:** tap Favorites from bottom navigation; tap Favorites from Settings
- **Key UI elements:** LazyColumn of favorite affirmation cards; Remove favorite action; Sync status row: local only, syncing, synced, sync error; Empty state explaining how to save an affirmation
- **States:** loading, empty, syncing, sync_error, populated

### Settings
- **Route:** `settings`
- **Purpose:** Central place for account, notifications, subscription, refund/cancellation, sync, and privacy controls.
- **Reached via:** tap Settings from bottom navigation; tap Settings icon from Home
- **Key UI elements:** Account status row; Notification enable switch and time picker; Subscription status row; Manage subscription button; Refund help button; Sync now button; Privacy policy link; Delete local data button
- **States:** loading, loaded_signed_out, loaded_signed_in, saving, error

### SupportAndBilling
- **Route:** `support_billing`
- **Purpose:** Provide self-serve cancellation, subscription management, and refund request paths without email-only support.
- **Reached via:** tap Manage subscription from Paywall; tap Manage subscription from Settings; tap Refund help from Settings
- **Key UI elements:** Open Google Play subscription management button; Open Google Play refund request button; Copy refund URL fallback button; Current entitlement and renewal date from RevenueCat; Text explaining that billing is handled by Google Play
- **States:** loading, loaded_with_subscription, loaded_without_subscription, external_intent_failed, error

### Account
- **Route:** `account`
- **Purpose:** Sign in, create account, sign out, and trigger restore/sync for settings and favorites.
- **Reached via:** tap Account from Onboarding; tap Account from Settings; tap Sync status from Favorites
- **Key UI elements:** Email field; Password field; Create account button; Sign in button; Sign out button when authenticated; Sync status and last synced timestamp; Restore from cloud button
- **States:** signed_out, authenticating, auth_error, signed_in_syncing, signed_in_synced, signed_in_sync_error

### WidgetConfig
- **Route:** `widget_config`
- **Purpose:** Configure the home screen widget to use the same safe filters and refresh cadence as the app.
- **Reached via:** tap Widget from Settings; Android widget configuration flow after adding widget
- **Key UI elements:** Preview affirmation card; Use same filters explanation; Refresh now button; Open Android widget placement instructions
- **States:** loading, preview_available, no_safe_affirmations, error

## Data model

### Affirmation (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable ID from bundled JSON asset |
| text | `String` | affirmation body shown to the user |
| tone | `String` | one of gentle, direct, hopeful |
| category | `String` | top-level content category such as self_worth, grief, confidence, calm |
| tagsJson | `String` | JSON array of tags used for filtering; stored as String with kotlinx.serialization |
| isReligious | `Boolean` | true only for religious or explicitly spiritual content |
| unsafeContextsJson | `String` | JSON array of sensitive contexts where this line must never be shown, such as assault or trauma |
| createdAtMillis | `Long` | asset build timestamp for deterministic ordering |

### UserPreference (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| userId | `String` | primary key; Firebase UID when signed in, otherwise local_device |
| optedOutCategoriesJson | `String` | JSON array of categories the user disabled, including religious if opted out |
| sensitiveContextsJson | `String` | JSON array containing grief, trauma, assault, or empty for none |
| tone | `String` | one of gentle, direct, hopeful |
| notificationsEnabled | `Boolean` | whether daily affirmation notifications are enabled |
| notificationHour | `Int` | 0 through 23 local time |
| notificationMinute | `Int` | 0 through 59 local time |
| updatedAtMillis | `Long` | last local update timestamp used for last-write-wins sync |

### Favorite (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| affirmationId | `String` | primary key component; foreign key to Affirmation.id |
| userId | `String` | primary key component; Firebase UID or local_device |
| createdAtMillis | `Long` | time favorite was added |
| pendingSync | `Boolean` | true until Firestore write succeeds for signed-in users |

### DeliveryLog (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| affirmationId | `String` | foreign key to Affirmation.id |
| surface | `String` | home, notification, or widget |
| shownAtMillis | `Long` | time this affirmation was displayed |

### SubscriptionEntitlementCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| userId | `String` | primary key; Firebase UID or local_device |
| isPremium | `Boolean` | true when RevenueCat entitlement premium is active |
| renewalAtMillis | `Long` | nullable represented as 0 when RevenueCat has no expiration date |
| lastCheckedAtMillis | `Long` | time CustomerInfo was last fetched |

### FirestoreUserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | document ID under users/{uid} |
| email | `String` | email address from Firebase Auth |
| optedOutCategoriesJson | `String` | synced copy of UserPreference.optedOutCategoriesJson |
| sensitiveContextsJson | `String` | synced copy of UserPreference.sensitiveContextsJson |
| tone | `String` | synced copy of UserPreference.tone |
| notificationsEnabled | `Boolean` | synced copy of notification preference |
| notificationHour | `Int` | synced copy of notification hour |
| notificationMinute | `Int` | synced copy of notification minute |
| updatedAtMillis | `Long` | last-write-wins sync timestamp |

### FirestoreFavorite (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| affirmationId | `String` | document ID under users/{uid}/favorites/{affirmationId} |
| createdAtMillis | `Long` | favorite creation timestamp |

## Features

### Honest onboarding with content opt-outs

On first launch, the app asks for tone, sensitive context, religious/spiritual opt-out, notification preference, and optional account sign-in before showing affirmations.

- **Answers complaint:** Mistargeted, generic content Religious content pushed onto users who didn't opt in, and generic affirmations shown without regard to a sensitive context (grief, trauma) the user disclosed to the app.

- **Screens:** Onboarding, Preferences, Account

- **Estimated hours:** 18

**Implementation notes:** Implement OnboardingViewModel with a UserPreference draft. Validate that tone is selected and notification time is valid before saving. Store preferences in Room immediately. If FirebaseAuth.currentUser is non-null, write the same fields to Firestore users/{uid} using updatedAtMillis=System.currentTimeMillis(). Religious/spiritual opt-out is represented by adding religious to optedOutCategoriesJson and by setting a Boolean filter that excludes Affirmation.isReligious=true. Sensitive contexts are stored as exact strings grief, trauma, assault. If the user skips account creation, set userId to local_device and show copy: Favorites and settings stay on this device until you sign in.

**Acceptance criteria:**
- A new install opens Onboarding before Home.
- Selecting religious/spiritual opt-out persists a UserPreference whose optedOutCategoriesJson contains religious.
- Selecting assault persists sensitiveContextsJson containing assault.
- Skipping account creation stores userId as local_device and does not attempt a Firestore write.
- Editing the same fields from Preferences updates Home filtering without restarting the app.

### Tagged safe affirmation feed

The Home feed selects affirmations from a locally bundled tagged library while excluding opted-out categories and unsafe lines for disclosed grief, trauma, or assault contexts.

- **Answers complaint:** They just charge you. This app says things like "You get what you deserve." to people who have told the app they were assaulted

- **Screens:** Home, Preferences, Favorites

- **Estimated hours:** 36

**Implementation notes:** Ship a bundled assets/affirmations_v1.json file decoded with kotlinx.serialization on first app start into the Affirmation Room table. Each entry must include text, tone, category, tags, isReligious, and unsafeContexts. The repository query first excludes isReligious=true when religious is opted out, then excludes any row whose unsafeContextsJson intersects the user's sensitiveContextsJson, then filters to the selected tone if at least one safe row exists for that tone. If no row remains, Home shows empty_after_filters with text: No safe affirmation is available for these filters yet. Never fall back to unsafe or religious rows to fill the feed. Add a static forbidden phrase unit-test fixture containing You get what you deserve.; that phrase must either be absent from assets or tagged unsafe for assault, trauma, and grief.

**Acceptance criteria:**
- With sensitive context assault selected, an affirmation whose text is You get what you deserve. is never returned by the feed repository.
- With religious opted out, no Affirmation where isReligious=true appears on Home, Notifications, Widget, or Favorites suggestions.
- If filters remove every row, Home displays the empty_after_filters state instead of showing a random unfiltered affirmation.
- Tapping Next affirmation records a DeliveryLog with surface home.

### Daily notification scheduling

The app sends one daily affirmation notification at the user-selected local time using the same safety filters as Home.

- **Answers complaint:** baseline parity

- **Screens:** Onboarding, Settings, Home

- **Estimated hours:** 18

**Implementation notes:** Request POST_NOTIFICATIONS on Android 13+ only after the user enables notifications in Onboarding or Settings. Use WorkManager with a OneTimeWorkRequest scheduled for the next selected local time; when the worker completes, enqueue the next one for the following day. In DailyAffirmationWorker, read UserPreference from Room, call the same safe feed repository used by Home, and post a notification through NotificationManagerCompat with channel id daily_affirmations. If no safe affirmation exists, do not post a notification and write a DeliveryLog with affirmationId empty and surface notification_empty. Tapping the notification opens MainActivity with route home.

**Acceptance criteria:**
- When notifications are enabled for 09:00, WorkManager has exactly one pending daily affirmation work item for the next 09:00 local time.
- The worker uses the same religious and sensitive-context filters as Home.
- If POST_NOTIFICATIONS is denied, Settings shows notifications disabled and no worker is enqueued.
- Tapping a posted notification opens Home.

### Home screen safe affirmation widget

A Glance widget displays a current safe affirmation and opens the app when tapped.

- **Answers complaint:** baseline parity

- **Screens:** WidgetConfig, Home, Preferences

- **Estimated hours:** 18

**Implementation notes:** Create a GlanceAppWidget that reads the latest safe affirmation from Room through a small repository entry point callable from the widget update worker. Use the same filtering logic as Home. The widget layout contains only the affirmation text and a small KindCue label; no imagery. Add a Refresh now action that triggers WidgetRefreshWorker and updates the widget state. If no safe affirmation is available, show Safe affirmation unavailable for current filters and a tap action to open Preferences.

**Acceptance criteria:**
- The widget never displays an affirmation excluded by religious opt-out or sensitive-context unsafeContexts.
- Tapping the widget opens Home.
- When all affirmations are filtered out, the widget shows the unavailable message instead of an unfiltered affirmation.
- Refresh now changes the displayed affirmation when at least two safe affirmations exist.

### Upfront subscription paywall with renewal reminder

The app sells only the disclosed $2.99/month subscription, with no free trial, no annual plan, restore purchases, and an optional renewal reminder based on RevenueCat entitlement data.

- **Answers complaint:** No-warning trial-to-annual billing The largest cluster by far — the app claims it will notify before charging and reviewers say it simply doesn't.

- **Screens:** Paywall, Settings, SupportAndBilling

- **Estimated hours:** 24

**Implementation notes:** Configure RevenueCat product identifier monthly_premium mapped to entitlement premium. The Paywall must show the exact price text $2.99/month and the sentence No free trial. No annual plan. Cancel any time in Google Play. Do not add trial eligibility logic. On purchase, call Purchases.sharedInstance.purchaseWith and cache CustomerInfo.entitlements["premium"].isActive plus expirationDate in SubscriptionEntitlementCache. If expirationDate exists and POST_NOTIFICATIONS is granted, schedule a OneTimeWorkRequest named renewal_reminder for 3 days before expirationDate at 10:00 local time; if that time is already past, do not schedule. The reminder notification text is KindCue renews soon at $2.99/month. Manage or cancel in Google Play. Tapping it opens SupportAndBilling.

**Acceptance criteria:**
- Paywall contains $2.99/month and does not contain free trial, yearly, annual, or $47.99-$75 text.
- A successful RevenueCat purchase sets isPremium=true in SubscriptionEntitlementCache.
- Restore purchases refreshes CustomerInfo and updates isPremium without making a purchase.
- If RevenueCat provides an expiration date 10 days away and notifications are granted, one renewal_reminder work item is scheduled for 3 days before that date.
- No annual SKU is referenced in code or UI.

### Self-serve cancellation and refund paths

Settings and Paywall expose working Google Play subscription management and refund-help links without requiring email support.

- **Answers complaint:** Refund/cancellation friction Broken refund links and unresponsive support drag disputes out for weeks.

- **Screens:** Settings, SupportAndBilling, Paywall

- **Estimated hours:** 10

**Implementation notes:** SupportAndBilling builds the subscription management URI as https://play.google.com/store/account/subscriptions?package=${BuildConfig.APPLICATION_ID} and opens it with Intent.ACTION_VIEW. The refund request URI is the official Google Play refund workflow https://support.google.com/googleplay/workflow/9813244. Wrap startActivity in try/catch; on ActivityNotFoundException or any RuntimeException, show external_intent_failed state with the exact URI and a Copy link button using ClipboardManager. Do not display an email address as the primary cancellation or refund mechanism.

**Acceptance criteria:**
- Manage subscription button launches an ACTION_VIEW intent containing play.google.com/store/account/subscriptions.
- Refund help button launches an ACTION_VIEW intent containing support.google.com/googleplay/workflow/9813244.
- If launching either intent throws, the screen displays the exact URL and a Copy link button.
- No screen tells the user that cancellation requires emailing support.

### Cross-device sync for favorites and settings

Signed-in users have favorites, content filters, tone, and notification settings synced to Firestore and restored on a new phone.

- **Answers complaint:** Sync/data loss A smaller complaint: favorites and settings don't carry over on a phone change despite being signed in.

- **Screens:** Account, Favorites, Settings, Onboarding

- **Estimated hours:** 26

**Implementation notes:** Use Firebase Auth email/password for sign-in and account creation. On sign-in, fetch users/{uid}; if remote updatedAtMillis is newer than local UserPreference.updatedAtMillis, overwrite local preference and reschedule notifications. Otherwise write local preference to Firestore. Favorites sync under users/{uid}/favorites/{affirmationId}; local favorites with pendingSync=true are uploaded after sign-in, then all remote favorites are merged locally by affirmationId. Use last-write-wins only for UserPreference, and additive merge for favorites. Account screen Restore from cloud reruns the fetch/merge flow. If Firestore fails, keep local data and mark sync_error without deleting favorites.

**Acceptance criteria:**
- After signing in on device A and favoriting an affirmation, signing in with the same account on device B downloads that favorite.
- Changing religious opt-out on device A and then tapping Restore from cloud on device B updates device B's local UserPreference when the remote updatedAtMillis is newer.
- A Firestore write failure leaves the local favorite visible and sets pendingSync=true.
- Signing out does not delete local favorites unless the user taps Delete local data in Settings.

### Safety disclaimers and non-crisis handling

The app clearly states it is not therapy or crisis support and avoids presenting canned affirmations as treatment for grief, trauma, or assault.

- **Answers complaint:** Let users opt out of content categories cleanly, and never serve canned affirmations into a disclosed crisis context without a safeguard.

- **Screens:** Onboarding, Home, Preferences, Settings

- **Estimated hours:** 10

**Implementation notes:** Show a non-therapy disclaimer in Onboarding and Settings: KindCue is not therapy, medical care, or crisis support. It only filters affirmations based on your preferences. On Home, if sensitiveContextsJson is non-empty, show a small Safety filters active chip that opens Preferences. Do not show crisis hotlines unless the user taps a More support info row in Settings; because the report does not ask for crisis intervention, the default product behavior is filtering plus non-therapy disclosure, not emergency support. The content repository must treat any missing unsafeContextsJson in an affirmation asset as unsafe for grief, trauma, and assault, so incomplete content never leaks into sensitive contexts.

**Acceptance criteria:**
- Onboarding displays the exact non-therapy disclaimer before saving preferences.
- When any sensitive context is selected, Home shows Safety filters active.
- An affirmation asset missing unsafeContextsJson is excluded for users with grief, trauma, or assault selected.
- The app does not claim to treat trauma, grief, assault recovery, anxiety, depression, or any medical condition.

## Store listing

- **Title:** KindCue Affirmations
- **Short description:** Upfront $2.99 affirmations with safe topic filters.
- **Category:** Health & Fitness
- **Keywords:** affirmations, daily affirmations, positive affirmations, self care, grief affirmations, trauma aware, safe affirmations, motivation, wellness widget, subscription reminder
- **Icon prompt:** Create a simple Android launcher icon for an app called KindCue: a centered rounded heart glyph with a small speech-cue notch, calm deep green #2F6F5E on warm cream #FFF9F1, flat vector style, no text, no religious symbols, no gradients, high contrast, safe margins for adaptive icon foreground.

**Long description:**

KindCue is a calmer affirmations app built around two promises: clear billing and safer content filters.

Start with your preferences, including tone, religious/spiritual opt-outs, and sensitive contexts such as grief, trauma, or assault. KindCue uses those choices to avoid showing canned lines in contexts where they do not belong.

Pricing is simple: $2.99/month, disclosed upfront. No free trial. No annual plan. No surprise trial-to-year conversion. Manage or cancel through Google Play from inside the app.

Save favorite affirmations, receive one daily reminder if you enable notifications, and add a simple home screen widget. Sign in to sync favorites and settings across devices.

KindCue is not therapy, medical care, or crisis support. It is an affirmations and reflection app with user-controlled filters.

## Legal

- **Regulated category:** health
- **Privacy policy URL:** https://kindcue.app/privacy (privacy claims verified: no)
- **Data collected:** Email address for Firebase sign-in; Sensitive context selections such as grief, trauma, or assault; Content category opt-outs including religious/spiritual preference; Tone preference; Favorite affirmation IDs; Notification enabled state and selected notification time; Subscription entitlement status and purchase identifiers from RevenueCat

## Test plan

### 1. They SAY they will notify you before they charge you after the free trial. They don't. They just charge you. (unit)

1. Instantiate PaywallUiModel for the only configured product monthly_premium.
2. Render the Paywall composable in a Compose UI test with fake RevenueCat product price $2.99/month.
3. Query all visible text nodes.
4. Assert no visible text contains free trial, annual, yearly, $47.99, $75, or trial.
5. Assert visible text contains $2.99/month and No free trial. No annual plan. Cancel any time in Google Play.

**Expected:** The paywall exposes only $2.99/month and explicit no-trial/no-annual copy, with no trial or annual wording anywhere.

### 2. This app says things like "You get what you deserve." to people who have told the app they were assaulted (unit)

1. Create an in-memory Room database with one Affirmation text You get what you deserve., unsafeContextsJson ["assault","trauma","grief"], isReligious false.
2. Insert UserPreference with sensitiveContextsJson ["assault"], no category opt-outs, tone gentle.
3. Call SafeAffirmationRepository.nextAffirmation(surface="home").
4. Repeat with a second safe affirmation inserted so the repository has a valid alternative.

**Expected:** The repository never returns the You get what you deserve. affirmation; when a safe alternative exists it returns the alternative, and when none exists it returns an empty-safe-result state.

### 3. Religious content pushed onto users who didn't opt in (unit)

1. Create an in-memory Room database with one religious Affirmation isReligious=true and one nonreligious Affirmation isReligious=false.
2. Insert UserPreference with optedOutCategoriesJson ["religious"], empty sensitiveContextsJson, tone gentle.
3. Call SafeAffirmationRepository.listSafeAffirmations().
4. Inspect every returned row.

**Expected:** Every returned affirmation has isReligious=false.

### 4. Refund/cancellation friction Broken refund links and unresponsive support drag disputes out for weeks. (instrumented)

1. Launch SupportAndBilling with a fake ActivityResult launcher that records ACTION_VIEW intents.
2. Tap Manage subscription.
3. Record the launched URI.
4. Tap Refund help.
5. Record the launched URI.
6. Replace launcher with one that throws ActivityNotFoundException and tap Refund help again.

**Expected:** Manage subscription launches a URI containing play.google.com/store/account/subscriptions; Refund help launches https://support.google.com/googleplay/workflow/9813244; when launch fails, the screen shows the exact refund URL and a Copy link button.

### 5. Sync/data loss A smaller complaint: favorites and settings don't carry over on a phone change despite being signed in. (instrumented)

1. Use Firebase emulator or fake Firebase repositories with UID user123.
2. On simulated device A, sign in as user123, select religious opt-out, and favorite affirmation aff_001.
3. Force sync to remote fake Firestore.
4. Create a fresh in-memory database for simulated device B.
5. Sign in as user123 on device B and call restoreFromCloud().
6. Read UserPreference and Favorite tables on device B.

**Expected:** Device B has optedOutCategoriesJson containing religious and a Favorite row for aff_001.

### 6. baseline parity (instrumented)

1. Launch Settings with notifications disabled.
2. Enable notifications and select 09:00.
3. Grant POST_NOTIFICATIONS in the test environment.
4. Read WorkManager test driver queued work specs tagged daily_affirmation.
5. Run the DailyAffirmationWorker with a database containing one safe affirmation.
6. Inspect posted notification through the test notification facade.

**Expected:** Exactly one daily_affirmation work item is scheduled and the worker posts a notification containing the safe affirmation text.

### 7. baseline parity (manual)

1. Install a release build on an Android 13 or newer device.
2. Complete Onboarding with religious opt-out enabled and sensitive context assault selected.
3. Add the KindCue widget to the home screen.
4. Tap Refresh now on the widget.
5. Tap the widget body.

**Expected:** The widget displays a nonreligious affirmation safe for assault context, and tapping it opens the app Home screen.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release.keystore -alias release -keyalg RSA -keysize 2048 -validity 10000 -storepass changeit -keypass changeit -dname "CN=KindCue,O=SoloBuilder,C=US"
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/release.keystore -Pandroid.injected.signing.store.password=changeit -Pandroid.injected.signing.key.alias=release -Pandroid.injected.signing.key.password=changeit
```

## Human gates still required

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