# ClearTone Eject — build spec

A one-tap speaker water-eject cleaner that runs immediately, uses no network permission, and offers only a clear $2.99 one-time unlock instead of a hidden subscription.

## 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:** Speaker Cleaner: Water Eject
- **Package id:** `com.clean.speaker.ejectwater`
- **Google Play:** https://play.google.com/store/apps/details?id=com.clean.speaker.ejectwater
- **appy.fyi report:** https://appy.fyi/report/com.clean.speaker.ejectwater
- **Category:** Music & Audio

## Overview

- **Working name:** ClearTone Eject (trademark cleared: no)
- **Package id:** `fyi.appy.cleartoneeject`
- **Min / target SDK:** 23 / 35
- **Backend:** none
- **Estimated build time:** 2 weeks
- **Pricing:** one-time purchase, $2.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** none

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

- No subscription, free trial, recurring billing, or paywall before the cleaner runs.
- No backend, account system, cloud sync, analytics pipeline, or server-side device profile storage.
- No microphone-based auto-stop, AI detection, or personalized cleaning mode in v1.
- No network ad SDK in v1; preserving a zero-network-permission manifest is prioritized because the report explicitly identifies network permission suspicion as a market gap.
- No claim that the app repairs damaged speakers; copy and UI must describe only playing a water-eject tone.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose host Activity integration | `androidx.activity:activity-compose:1.9.3` |
| Material 3 Compose UI components | `androidx.compose.material3:material3:1.3.0` |
| Core Compose UI runtime and primitives | `androidx.compose.ui:ui:1.7.4` |
| Compose UI tooling previews for debug builds | `androidx.compose.ui:ui-tooling-preview:1.7.4` |
| Compose Navigation graph between cleaner, unlock, and settings screens | `androidx.navigation:navigation-compose:2.8.4` |
| Lifecycle-aware state collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| ViewModel support for Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7` |
| Google Play one-time in-app purchase for the $2.99 unlock | `com.android.billingclient:billing-ktx:7.1.1` |
| Kotlin coroutines for BillingClient callbacks and AudioTrack worker lifecycle | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |

## Design system

- **Primary color:** `#0E7C7B`
- **Background color:** `#F6FBFA`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `speaker-high` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light background is #F6FBFA with primary #0E7C7B. Dark theme uses #071F1F background, #56D6D3 primary, and #F2B8B5 error. Use rounded 24dp cards, a single large circular primary action button on the cleaner screen, and no splash paywall or modal on first launch.

## Screens

### Cleaner
- **Route:** `cleaner`
- **Purpose:** Primary one-tap screen that starts and stops the water-eject tone without ads, login, network access, or purchase prompts blocking first use.
- **Reached via:** app launch; tap Back from Unlock; tap Back from Settings; tap Run again after completion
- **Key UI elements:** Top app bar with title "ClearTone Eject" and settings gear button; Large circular Start Cleaning button when idle; Large circular Stop button while running; 30-second countdown timer; Animated wave rings around the speaker icon while tone is playing; Short safety note: "Plays a loud tone through your speaker. Lower volume if uncomfortable."; Completion card shown after the tone finishes with "Run again" and "Unlock ad-free support" buttons; Inline audio error banner with retry button
- **States:** idle, running, completed, audio_error

### Unlock
- **Route:** `unlock`
- **Purpose:** Transparent one-time purchase screen for the $2.99 unlock, with no subscription, no trial, and a visible refund-help path.
- **Reached via:** tap Unlock ad-free support on Cleaner completion card; tap One-time unlock from Settings
- **Key UI elements:** Headline "One-time unlock"; Price row showing the Play Billing localized price for product id remove_ads_299; Plain-language text: "No subscription. No free trial. No recurring charge."; Buy button that launches the Play Billing purchase flow; Restore purchase button; Refund help button opening Google Play refund help in the browser; Owned state card when the one-time purchase is already active; Billing error message with retry button
- **States:** loading_products, available, purchase_in_progress, owned, billing_unavailable, error

### Settings
- **Route:** `settings`
- **Purpose:** Trust and support screen showing the app requests no permissions, has no subscription, and provides restore/refund/privacy links.
- **Reached via:** tap settings gear on Cleaner
- **Key UI elements:** Permissions row reading "Requested permissions: none"; Offline row reading "Cleaner works without internet"; Billing row showing "One-time unlock" or "Unlocked"; Restore purchase button; Refund help button opening Google Play refund help in the browser; Privacy policy button opening the suggested privacy policy URL in the browser; App version text
- **States:** populated, billing_status_loading, billing_error

## Data model

_No persistent data._

## Features

### Immediate first-open cleaner access

The cleaner is usable from the first screen without showing an ad, subscription trial, account prompt, or purchase screen first.

- **Answers complaint:** Ads before the function.

- **Screens:** Cleaner

- **Estimated hours:** 10

**Implementation notes:** Set the Navigation start destination to route "cleaner". On first launch, render Cleaner in idle state with the Start Cleaning button enabled immediately. Do not show any Dialog, BottomSheet, BillingClient flow, onboarding carousel, or interstitial before the user can tap Start Cleaning. The only path to Unlock is a secondary button on the post-completion card or Settings. The Cleaner ViewModel must not query billing before enabling Start; billing state can load independently and must never block the audio button.

**Acceptance criteria:**
- After clearing app data and launching the app, the first visible screen is Cleaner.
- The Start Cleaning button is visible and enabled within 1 second of app launch on a normal device.
- No purchase UI, trial copy, ad UI, account UI, or modal is displayed before the user starts the cleaner.
- If Play Billing is unavailable, the Start Cleaning button still remains enabled.

### Deterministic speaker water-eject tone

The app plays a loud low-frequency sweep through Android media audio output for a fixed 30-second cleaning session.

- **Answers complaint:** baseline parity

- **Screens:** Cleaner

- **Estimated hours:** 20

**Implementation notes:** Implement a ToneGenerator class using android.media.AudioTrack directly, not TextToSpeech or MediaPlayer. Use sampleRate=44100, channelConfig=AudioFormat.CHANNEL_OUT_MONO, encoding=AudioFormat.ENCODING_PCM_16BIT, transferMode=AudioTrack.MODE_STREAM, AudioAttributes.USAGE_MEDIA, and AudioAttributes.CONTENT_TYPE_SONIFICATION. Generate signed 16-bit PCM sine samples in 1024-frame buffers. Sweep frequency linearly from 150 Hz to 220 Hz over each 3-second cycle, then repeat the cycle until 30 seconds elapse. Apply a 50 ms fade-in and 50 ms fade-out per start/stop to avoid clicks. Run writing on Dispatchers.Default in a Job owned by CleanerViewModel. Stop by cancelling the Job, calling audioTrack.pause(), audioTrack.flush(), audioTrack.stop() inside a try/catch, and then release(). If AudioTrack initialization fails or getState() is not STATE_INITIALIZED, set Cleaner state to audio_error with the caught message mapped to user text "Could not start speaker tone. Try again after closing other audio apps."

**Acceptance criteria:**
- Tapping Start Cleaning changes Cleaner state from idle to running and starts AudioTrack playback.
- The countdown begins at 30 seconds and reaches completed without user input.
- Tapping Stop before 30 seconds stops AudioTrack playback within 300 ms and returns the screen to idle.
- The generated PCM buffer contains non-zero samples and zero-crossing estimates for the first second fall between 145 Hz and 225 Hz.
- If AudioTrack cannot initialize, the Cleaner screen shows the audio_error banner and no crash occurs.

### Offline zero-permission manifest

The core cleaner ships without INTERNET, microphone, camera, location, storage, notification, or biometric permissions.

- **Answers complaint:** Permissions that don't match the job.

- **Screens:** Settings

- **Estimated hours:** 8

**Implementation notes:** Do not add any <uses-permission> element to AndroidManifest.xml. Do not include AdMob, analytics, crash reporting, remote config, or any SDK that injects INTERNET through its manifest. Use only local AudioTrack playback and Play Billing; BillingClient communicates through the Play Store app and does not require declaring INTERNET in this app. Add a Settings row that reads "Requested permissions: none" so users can verify the trust claim in-app. The app may open external browser URLs for refund/privacy via ACTION_VIEW Intents; this still requires no app manifest permission.

**Acceptance criteria:**
- The merged release manifest contains zero uses-permission entries.
- The APK/AAB manifest does not contain android.permission.INTERNET.
- The cleaner starts, runs for 30 seconds, and completes while the device is in airplane mode.
- Settings displays exactly "Requested permissions: none".

### Transparent $2.99 one-time unlock

The app offers only a clearly labeled non-recurring Play Billing in-app product and never offers a subscription or trial.

- **Answers complaint:** A hidden, hard-to-escape subscription.

- **Screens:** Unlock, Settings, Cleaner

- **Estimated hours:** 16

**Implementation notes:** Configure BillingClient with enablePendingPurchases(). Query ProductDetails for exactly one product id, "remove_ads_299", using BillingClient.ProductType.INAPP. Do not query BillingClient.ProductType.SUBS anywhere in the codebase. On Unlock screen, show the localized price from ProductDetails.oneTimePurchaseOfferDetails.formattedPrice, plus static copy "No subscription. No free trial. No recurring charge." The Buy button builds BillingFlowParams with the one ProductDetailsParams for remove_ads_299 and calls launchBillingFlow(activity, params). Treat Purchase.PurchaseState.PURCHASED as owned only after acknowledging unacknowledged purchases with acknowledgePurchase(). Restore purchase queries QueryPurchasesParams for ProductType.INAPP and sets owned when remove_ads_299 is found. The unlock must not gate the Start Cleaning button; it is monetization/support only.

**Acceptance criteria:**
- Searching the codebase for ProductType.SUBS returns no production-code references.
- Unlock screen shows "No subscription. No free trial. No recurring charge." whenever the product is available.
- The Play purchase sheet is launched only after the user taps Buy on Unlock.
- A purchased remove_ads_299 product is acknowledged if needed and then shown as owned.
- Restoring purchases updates Unlock and Settings to the owned state when remove_ads_299 already exists.
- The Cleaner Start button works before and after purchase.

### Visible refund and support path

The app gives users an obvious path to Google Play refund help instead of trapping them in a purchase funnel.

- **Answers complaint:** A hidden, hard-to-exit subscription.

- **Screens:** Unlock, Settings

- **Estimated hours:** 6

**Implementation notes:** Add a Refund help button to both Unlock and Settings. On tap, launch an ACTION_VIEW Intent for Uri.parse("https://support.google.com/googleplay/answer/2479637"). Wrap startActivity in runCatching; if no browser is available, show a Compose snackbar containing the full URL text so the user can copy it. Do not label this as in-app cancellation because the product is one-time INAPP, not SUBS. Include explanatory text near the button: "Purchases and refunds are handled by Google Play."

**Acceptance criteria:**
- Unlock screen contains a visible Refund help button without requiring a purchase first.
- Settings contains a visible Refund help button.
- Tapping Refund help sends an ACTION_VIEW Intent with URL https://support.google.com/googleplay/answer/2479637.
- If ACTION_VIEW fails, a snackbar displays the same URL.

### Post-value monetization only

Any monetization prompt appears only after a cleaning session completes or when the user opens Settings.

- **Answers complaint:** Ads before the function.

- **Screens:** Cleaner, Settings, Unlock

- **Estimated hours:** 8

**Implementation notes:** Do not render the Unlock entry point in the primary idle state above the fold. After ToneGenerator reports natural completion at 30 seconds, set Cleaner state to completed and show a completion card with two actions: primary "Run again" and secondary "Unlock ad-free support". If the user stops manually before completion, return to idle and do not show the unlock card. The Settings screen may include a One-time unlock row. This preserves the report's requirement that the function is the trust-builder and is not gated before value is delivered.

**Acceptance criteria:**
- Fresh launch idle state does not show a purchase modal or full-screen unlock screen.
- Stopping the tone manually before 30 seconds does not show the completion unlock card.
- After a full 30-second session, Cleaner shows a completion card with Run again as the primary action.
- The Unlock screen is reachable from the completion card and Settings only.

### Device audio behavior test pass

The app includes a repeatable manual device test checklist for speaker playback, stop behavior, airplane-mode operation, and volume safety.

- **Answers complaint:** baseline parity

- **Screens:** Cleaner

- **Estimated hours:** 12

**Implementation notes:** Add a docs/device-test-checklist.md file with rows for device model, Android version, built-in speaker audible yes/no, Bluetooth disconnected yes/no, airplane mode run completes yes/no, Stop response under 300 ms yes/no, and user-observed distortion yes/no. During implementation, run the checklist on at least three Android devices or emulators where physical speaker testing is possible for real devices. The app code should not force maximum volume; it uses the current STREAM_MUSIC volume and shows the safety note before playback.

**Acceptance criteria:**
- docs/device-test-checklist.md exists in the repository.
- The checklist includes explicit airplane-mode and Stop-response rows.
- The Cleaner screen does not call AudioManager.setStreamVolume.
- The Cleaner screen displays a safety note before Start Cleaning is tapped.

## Store listing

- **Title:** ClearTone Speaker Cleaner
- **Short description:** One-tap water-eject tone. No subscription. No network permission.
- **Category:** Tools
- **Keywords:** speaker cleaner, water eject, speaker water remover, phone speaker cleaner, offline utility, no subscription
- **Icon prompt:** Create a minimalist Android app icon for a speaker water-eject utility. Use a teal rounded-square background (#0E7C7B), a centered white speaker glyph, and three curved aqua sound waves pushing small water droplets outward. Flat vector style, high contrast, no text, no brand logos, suitable for Play Store launcher icon at 512x512.

**Long description:**

ClearTone Speaker Cleaner plays a focused low-frequency sweep designed to help eject water from a phone speaker after splashes or moisture exposure.

Open the app, tap Start Cleaning, and let the 30-second tone run. No account, no subscription trial, and no ad wall before the tool.

Built for trust:
• Cleaner runs immediately on first open
• Works offline
• Requests no Android permissions
• No hidden subscription or recurring charge
• Optional $2.99 one-time unlock/support purchase
• Clear refund help through Google Play

Use responsibly: this app plays a loud tone through your speaker. Lower your media volume if the sound is uncomfortable. It cannot repair physically damaged hardware.

## Legal

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

## Test plan

### 1. Ads before the function. (instrumented)

1. Clear app data with adb shell pm clear for the app package.
2. Launch MainActivity.
3. Wait up to 1000 ms for the first Compose frame.
4. Assert that a node with text "Start Cleaning" exists and is enabled.
5. Assert that no node contains text "Subscribe", "trial", "Buy", "Unlock", or "ad" as a blocking modal before the first cleaning run.
6. Tap "Start Cleaning".

**Expected:** Cleaner enters running state and shows the 30-second countdown without any purchase, trial, ad, or modal appearing first.

### 2. baseline-parity deterministic frequency sweep. (unit)

1. Instantiate the PCM sweep generator with sampleRate=44100, startHz=150, endHz=220, cycleMillis=3000.
2. Generate exactly 44100 samples for the first second.
3. Assert that at least one sample is non-zero.
4. Count positive-going zero crossings in the generated one-second buffer.
5. Convert crossings to estimated Hz and compare with the configured sweep range.

**Expected:** The estimated frequency for the first second is between 145 Hz and 225 Hz and the buffer contains no NaN, overflow, or all-zero output.

### 3. baseline-parity stop behavior. (instrumented)

1. Launch MainActivity.
2. Tap "Start Cleaning".
3. Wait until the countdown text shows a value below 30 seconds.
4. Tap "Stop".
5. Wait 300 ms.
6. Read the Cleaner screen state.

**Expected:** The screen returns to idle with "Start Cleaning" visible and enabled, and the running countdown is no longer displayed.

### 4. Permissions that don't match the job. (manual)

1. Build the release AAB.
2. Run ./gradlew :app:processReleaseMainManifest.
3. Open app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml.
4. Search for every <uses-permission> element.
5. Install the app on a device.
6. Enable airplane mode.
7. Launch the app and run one full 30-second cleaning session.

**Expected:** The merged manifest contains no <uses-permission> entries, including no android.permission.INTERNET, and the 30-second cleaner completes in airplane mode.

### 5. A hidden, hard-to-escape subscription. (unit)

1. Inspect the BillingRepository product query configuration in a unit test.
2. Assert that the only configured product id is "remove_ads_299".
3. Assert that its BillingClient product type is ProductType.INAPP.
4. Assert that no production billing query requests ProductType.SUBS.

**Expected:** Billing configuration contains exactly one one-time INAPP product and zero subscription products.

### 6. A hidden, hard-to-exit subscription. (instrumented)

1. Launch the app.
2. Navigate to Settings using the gear button.
3. Tap "One-time unlock".
4. Wait for the Unlock screen.
5. Assert that text "No subscription. No free trial. No recurring charge." is visible.
6. Assert that a "Refund help" button is visible.
7. Tap "Refund help" while using an intent monitor for ACTION_VIEW.

**Expected:** Unlock screen plainly states there is no subscription or trial, and tapping Refund help emits an ACTION_VIEW intent for https://support.google.com/googleplay/answer/2479637.

### 7. Ads before the function. (instrumented)

1. Launch the app with fresh data.
2. Tap "Start Cleaning".
3. Tap "Stop" before the countdown completes.
4. Observe the Cleaner screen.
5. Start again and allow the countdown to complete naturally.
6. Observe the completed state.

**Expected:** No unlock prompt appears after a manually stopped partial run; after a full completed run, a completion card appears with primary action "Run again" and secondary action "Unlock ad-free support".

## Build instructions

```sh
set -e
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew lintDebug
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit123 -keypass changeit123 -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ClearTone Eject Upload,O=Solo Builder,C=US"
RELEASE_STORE_FILE=$PWD/release-upload.jks RELEASE_STORE_PASSWORD=changeit123 RELEASE_KEY_ALIAS=upload RELEASE_KEY_PASSWORD=changeit123 ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
