# TapOnce Remote — build spec

A clean universal TV remote where volume, power, and channel controls stay free and ads never interrupt every button press.

## 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 the optional api_access endpoints), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** Universal TV Remote for All TV
- **Package id:** `com.boost.universal.remote`
- **Google Play:** https://play.google.com/store/apps/details?id=com.boost.universal.remote
- **appy.fyi report:** https://appy.fyi/report/com.boost.universal.remote
- **Category:** Tools

## Overview

- **Working name:** TapOnce Remote (trademark cleared: no)
- **Package id:** `com.appyfyi.taponce.remote`
- **Min / target SDK:** 23 / 35
- **Backend:** none
- **Estimated build time:** 5 weeks
- **Pricing:** one-time purchase, $2.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `INTERNET`

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

- No subscription plan, free trial, weekly billing, or card-required trial in v1.
- No cloud account, device sync, shared household profiles, or server-side remote control in v1.
- No AI assistant, AI setup wizard, or AI-generated device configuration in v1.
- No attempt to support every proprietary TV brand protocol beyond Roku ECP, Google Cast basics, common SSDP/DIAL discovery, and basic Android IR fallback in v1.
- No per-button interstitial, rewarded, or forced ad placement anywhere in the remote-control flow.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Android Kotlin core extensions | `androidx.core:core-ktx:1.13.1` |
| 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 Compose ViewModels | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Local persistence for saved TVs and last-used device | `androidx.room:room-runtime:2.6.1` |
| Room coroutine APIs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Coroutines for discovery, socket IO, billing, and command dispatch | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| HTTP client for Roku ECP discovery follow-up calls and command posts | `com.squareup.okhttp3:okhttp:4.12.0` |
| Google Cast discovery, session management, and Cast volume/media commands | `com.google.android.gms:play-services-cast-framework:21.5.0` |
| Launch-only ad placement for the free tier | `com.google.android.gms:play-services-ads:23.4.0` |
| One-time remove-ads in-app purchase | `com.android.billingclient:billing-ktx:7.1.1` |

## Design system

- **Primary color:** `#1E6B5C`
- **Background color:** `#F7FAF8`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `television-simple` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light theme background is #F7FAF8 with primary #1E6B5C; dark theme background is #101816 with primary #7ED9C4. Remote buttons use high-contrast filled or outlined Material 3 buttons with minimum 56dp touch targets. Do not use ad-like visual cards in the remote area.

## Screens

### LaunchAdGate
- **Route:** `launch`
- **Purpose:** Checks purchase state and, for non-purchased users, attempts to show at most one launch ad before routing to discovery or the last remote.
- **Reached via:** app launch
- **Key UI elements:** App logo; Short loading message: "Preparing remote"; Progress indicator; Skip-to-remote fallback after ad load timeout
- **States:** checking_purchase, loading_ad, showing_ad, ad_unavailable, purchased_no_ad, routing_error

### DeviceDiscovery
- **Route:** `devices`
- **Purpose:** Finds nearby WiFi TVs and Cast devices, shows saved TVs, and allows manual refresh or manual IP entry.
- **Reached via:** LaunchAdGate after first install; RemoteControl toolbar change-device action; Settings saved-devices action
- **Key UI elements:** Top app bar with title "Find TV"; Scanning status row; Saved devices section; Discovered devices list with protocol labels; Refresh button; Manual IP entry field; Connect button per device; IR fallback entry button
- **States:** loading_saved_devices, scanning, empty_no_saved_or_discovered, populated_saved_only, populated_discovered, network_error, manual_ip_validation_error

### RemoteControl
- **Route:** `remote/{deviceId}`
- **Purpose:** Primary remote with free power, volume, mute, channel, navigation, and playback controls.
- **Reached via:** tap a device on DeviceDiscovery; LaunchAdGate when a last-used saved device exists; IRFallback when user returns from IR mode
- **Key UI elements:** Connected TV name; Connection status chip; Power button; Volume up/down buttons; Mute button; Channel up/down buttons; D-pad with OK/select; Home and Back buttons; Playback controls for supported Cast/Roku devices; Touchpad button; Keyboard button; Settings button
- **States:** loading_device, connecting, connected, command_sending, command_failed, unsupported_command, device_offline, device_not_found

### TouchpadKeyboard
- **Route:** `remote/{deviceId}/touchpad`
- **Purpose:** Provides swipe navigation and text input for protocols that support directional commands or text entry.
- **Reached via:** tap Touchpad on RemoteControl; tap Keyboard on RemoteControl
- **Key UI elements:** Large touchpad surface; Gesture hint text; Text input field; Send text button; Clear text button; Protocol support message
- **States:** loading_device, ready, sending_gesture, sending_text, text_unsupported, command_failed

### IRFallback
- **Route:** `ir`
- **Purpose:** Provides basic infrared remote control when WiFi discovery or control is not available and the Android device has an IR emitter.
- **Reached via:** tap IR fallback on DeviceDiscovery; tap IR fallback from RemoteControl unsupported/offline message
- **Key UI elements:** IR hardware status banner; Brand/profile picker; Power button; Volume up/down buttons; Mute button; Channel up/down buttons; Test command button; Save IR profile button
- **States:** checking_ir_hardware, no_ir_hardware, ready_no_profile_selected, ready_profile_selected, transmitting, transmit_error

### Settings
- **Route:** `settings`
- **Purpose:** Shows ad-removal purchase status, saved devices, legal links, and confirms there is no subscription or trial billing.
- **Reached via:** tap Settings on RemoteControl; tap Settings from DeviceDiscovery top app bar
- **Key UI elements:** Purchase status row; Remove ads one-time purchase button; Restore purchase button; Saved devices list; Delete saved device buttons; Privacy policy link; No subscriptions/free trials explanatory text
- **States:** loading_purchase_state, not_purchased, purchase_in_progress, purchased, purchase_error, restore_no_purchase_found, saved_devices_empty, saved_devices_populated

## Data model

### SavedDevice (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| displayName | `String` | TV or receiver name shown in lists |
| protocol | `String` | one of ROKU_ECP, GOOGLE_CAST, SSDP_DIAL, IR_PROFILE, MANUAL_ROKU_ECP |
| ipAddress | `String?` | nullable for GOOGLE_CAST and IR_PROFILE |
| port | `Int?` | nullable; Roku ECP normally 8060 |
| castDeviceId | `String?` | nullable; Google Cast device identifier when discovered by Cast SDK |
| irProfileName | `String?` | nullable; selected bundled IR profile name |
| lastSeenAtEpochMillis | `Long` | updated whenever discovery or command succeeds |
| lastUsed | `Boolean` | only one row should be true; set false on all others when connecting |

## Features

### WiFi TV discovery and saved devices

Discovers nearby compatible TVs and stores selected devices locally for quick reconnection.

- **Answers complaint:** baseline parity

- **Screens:** DeviceDiscovery, RemoteControl

- **Estimated hours:** 48

**Implementation notes:** Implement a DiscoveryRepository with two parallel discovery paths. For Roku, send UDP SSDP M-SEARCH packets to 239.255.255.250:1900 with ST values `roku:ecp` and `ssdp:all`, wait in 3-second scan windows, parse `LOCATION` headers, then call `GET {location}/query/device-info` with OkHttp to read the device friendly name and confirm ECP support. For Cast, initialize `CastContext` and read available routes through the Cast framework; store the Cast device id and display name. The DeviceDiscovery screen starts scanning automatically, exposes a refresh button, and lets the user manually enter an IP address; manual IP creates `http://{ip}:8060/query/device-info` and saves it only after HTTP 200. Persist successful selections in Room `SavedDevice`, mark the selected device `lastUsed=true`, and route to `remote/{deviceId}`.

**Acceptance criteria:**
- A fake Roku server at port 8060 returning valid `/query/device-info` is shown in DeviceDiscovery with protocol label `Roku`.
- Selecting a discovered device inserts exactly one SavedDevice row and marks it as lastUsed.
- A manual IP that does not respond with HTTP 200 keeps the user on DeviceDiscovery and shows a validation error.
- If no devices are found and no devices are saved after a scan window, the empty state shows both Refresh and IR fallback actions.

### Free core remote controls

Provides power, volume, mute, channel, navigation, home/back, and supported playback controls without requiring payment.

- **Answers complaint:** Basic functions paywalled

- **Screens:** RemoteControl

- **Estimated hours:** 52

**Implementation notes:** Create a `RemoteProtocolAdapter` interface with `send(command: RemoteCommand): CommandResult`. For Roku ECP, map commands to `POST http://{ip}:8060/keypress/{key}` where keys are `Power`, `VolumeUp`, `VolumeDown`, `VolumeMute`, `ChannelUp`, `ChannelDown`, `Up`, `Down`, `Left`, `Right`, `Select`, `Home`, `Back`, `Play`, `Rev`, and `Fwd`. For Google Cast, support volume via `CastSession.setVolume`, mute via `CastSession.setMute`, and media controls through `RemoteMediaClient`; show `unsupported_command` for power/channel/navigation when Cast does not expose them. For SSDP/DIAL entries without a known command API, show the device but disable unsupported buttons with a visible message rather than pretending to send. Command buttons must never check purchase state before dispatch; purchase state only controls whether LaunchAdGate attempts a launch ad.

**Acceptance criteria:**
- With purchase state set to not purchased, tapping Volume Up on a Roku device sends one HTTP POST to `/keypress/VolumeUp` and does not open Settings or a purchase sheet.
- With purchase state set to not purchased, tapping Power sends one command attempt and does not display a paywall.
- Unsupported Cast power/channel buttons show an `unsupported_command` state message instead of launching billing.
- Every main remote button has a minimum touch target of 56dp.

### Touchpad and text input

Adds a gesture pad and keyboard entry for remotes where directional or text commands are supported.

- **Answers complaint:** baseline parity

- **Screens:** TouchpadKeyboard, RemoteControl

- **Estimated hours:** 32

**Implementation notes:** Build the TouchpadKeyboard screen with Compose pointer input. A single tap sends `Select`. A horizontal drag ending more than 48dp from start sends `Left` or `Right`; a vertical drag ending more than 48dp sends `Up` or `Down`; ignore smaller drags. Text input sends characters sequentially through the active adapter. For Roku ECP text, URL-encode each Unicode code point and send `POST /keypress/Lit_{encodedCharacter}` one character at a time with a 40ms delay between requests. If the active adapter returns `TEXT_UNSUPPORTED`, keep the typed text in the field and show a visible `text_unsupported` message.

**Acceptance criteria:**
- A right swipe of at least 48dp on the touchpad sends exactly one `Right` command.
- A tap without drag sends exactly one `Select` command.
- Typing `Hi!` for a Roku device sends `/keypress/Lit_H`, `/keypress/Lit_i`, and `/keypress/Lit_%21` in order.
- For a Cast-only device, pressing Send Text shows the text unsupported state and does not clear the text field.

### Basic IR fallback

Lets users try basic power, volume, mute, and channel commands through Android infrared hardware when WiFi control is unavailable.

- **Answers complaint:** baseline parity

- **Screens:** IRFallback, DeviceDiscovery

- **Estimated hours:** 32

**Implementation notes:** Use `getSystemService(ConsumerIrManager::class.java)` and call `hasIrEmitter()` before showing controls. Ship a bundled `assets/ir_profiles.json` containing named basic TV profiles with carrier frequency, protocol, and command bit patterns. Implement NEC waveform generation for profiles marked `NEC`: header 9000µs on/4500µs off, each bit as 560µs on plus 560µs off for 0 or 1690µs off for 1, and a final 560µs on pulse; pass the resulting microsecond pattern to `ConsumerIrManager.transmit(frequency, pattern)`. When no emitter exists, show `no_ir_hardware` and keep WiFi discovery available.

**Acceptance criteria:**
- On a device or emulator where `hasIrEmitter()` is false, IRFallback shows the no-IR state and no transmit buttons are enabled.
- Selecting an IR profile and tapping Volume Down calls the IR transmitter once with that profile's carrier frequency and generated pattern.
- Saving an IR profile creates a SavedDevice row with protocol `IR_PROFILE` and the selected profile name.
- A failed transmit shows `transmit_error` without crashing or deleting the selected profile.

### One launch ad per session

Keeps the free tier ad-supported while ensuring ads never appear after every remote button press.

- **Answers complaint:** Ad after every button

- **Screens:** LaunchAdGate, RemoteControl, TouchpadKeyboard, IRFallback

- **Estimated hours:** 16

**Implementation notes:** Implement an `AdSessionLimiter` held in the application process with a Boolean `launchAdAttempted`. LaunchAdGate calls `maybeShowLaunchAd()` only once per process when Play Billing says remove-ads is not purchased. Load a Google Mobile Ads interstitial using the debug test ad unit id in debug builds and a release ad unit id from BuildConfig in release builds. Set a 2500ms timeout; if loading or showing fails, route to the remote/discovery without retrying until the process restarts. RemoteControl, TouchpadKeyboard, IRFallback, and all command handlers must have no dependency on the ad loader and must never call ad display APIs.

**Acceptance criteria:**
- During one app process session, opening the app and pressing 20 remote buttons results in no more than one interstitial ad display attempt.
- If the launch ad fails to load within 2500ms, the app routes to the next screen and does not retry on button taps.
- Purchased users route through LaunchAdGate without any ad load request.
- No remote command handler imports or references the Google Mobile Ads SDK.

### One-time ad removal with no trial or subscription

Offers only an optional $2.99 lifetime ad-removal purchase and avoids trial billing entirely.

- **Answers complaint:** Deceptive trial billing

- **Screens:** Settings, LaunchAdGate

- **Estimated hours:** 20

**Implementation notes:** Use Google Play Billing with a single INAPP product id `remove_ads_lifetime`; do not configure or query any SUBS products. Settings shows the product as an optional one-time ad removal unlock and explicitly says core remote controls remain free. Query existing purchases on launch and on Settings open using BillingClient `queryPurchasesAsync` for `ProductType.INAPP`; consider the user purchased only when a purchase for `remove_ads_lifetime` is in PURCHASED state and acknowledged. Start purchase flow only from the Settings button. After purchase, acknowledge if needed and suppress future launch ads. There is no free trial screen, no card-required trial copy, and no recurring billing copy.

**Acceptance criteria:**
- The BillingClient product query uses only `ProductType.INAPP` and never `ProductType.SUBS`.
- The Settings screen displays a one-time remove-ads option and states that volume, power, and channel controls are free.
- Closing Settings without buying leaves the remote fully usable.
- After a successful acknowledged purchase, LaunchAdGate makes zero ad load attempts on subsequent launches.

## Store listing

- **Title:** TapOnce Remote
- **Short description:** Clean TV remote with free volume, power, and channel controls.
- **Category:** Tools
- **Keywords:** tv remote, universal remote, roku remote, chromecast remote, smart tv remote, wifi remote, ir remote
- **Icon prompt:** Create a modern Android app icon for a clean universal TV remote app. Use a rounded square background in deep teal #1E6B5C with a centered simple white television-and-remote glyph, flat vector style, high contrast, no text, no brand logos, no gradients, suitable for Play Store launcher icon.

**Long description:**

TapOnce Remote is a simple universal TV remote for nearby WiFi TVs and Cast devices, with basic IR fallback on phones that support it.

Core remote controls stay free: volume, power, mute, channel, navigation, and supported playback controls are not locked behind a subscription. The free version may show one launch ad per app session, but ads are never shown after every button press.

If you want an ad-free experience, there is one optional $2.99 lifetime remove-ads purchase. No subscription. No card-required free trial. No recurring remote-control bill.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://appy.fyi/privacy/taponce-remote (privacy claims verified: no)
- **Data collected:** Saved TV device names, IP addresses, Cast device identifiers, and selected IR profile names stored locally on the device; Advertising identifiers and ad interaction data processed by Google Mobile Ads for the free launch ad; Purchase token and purchase status processed by Google Play Billing for the one-time remove-ads unlock

## Test plan

### 1. Ad after every button (instrumented)

1. Install a debug build with a fake AdLoader injected that increments `showAttemptCount` instead of showing a real interstitial.
2. Set fake billing state to not purchased.
3. Launch the app and wait until RemoteControl is visible for a fake saved Roku device.
4. Tap Volume Up 5 times, Volume Down 5 times, Channel Up 5 times, Power 3 times, and OK 2 times.
5. Read `showAttemptCount` from the fake AdLoader.

**Expected:** `showAttemptCount` is 0 or 1 for the whole process session, and every tapped command is still dispatched to the fake remote adapter.

### 2. Basic functions paywalled (instrumented)

1. Start a local fake Roku HTTP server that records POST paths.
2. Insert a SavedDevice row with protocol `ROKU_ECP`, IP address `127.0.0.1`, and the fake server port.
3. Set fake billing state to not purchased.
4. Open `remote/{deviceId}`.
5. Tap Volume Up, Volume Down, Mute, Channel Up, Channel Down, and Power.

**Expected:** The fake server receives `/keypress/VolumeUp`, `/keypress/VolumeDown`, `/keypress/VolumeMute`, `/keypress/ChannelUp`, `/keypress/ChannelDown`, and `/keypress/Power`; no purchase sheet or Settings route is opened.

### 3. Deceptive trial billing (manual)

1. Install a fresh release-candidate build from internal testing.
2. Open the app, skip or dismiss any launch ad, and go to Settings.
3. Tap the remove-ads purchase button.
4. Inspect the Google Play purchase sheet before confirming or canceling.
5. Cancel the purchase and return to RemoteControl.

**Expected:** The purchase sheet shows a one-time in-app product for ad removal, not a subscription or trial; after canceling, volume, power, and channel controls remain usable.

### 4. baseline parity (unit)

1. Create a RokuProtocolAdapter with an OkHttp MockWebServer base URL.
2. Call `send(RemoteCommand.VolumeUp)`.
3. Call `sendText("Hi!")`.
4. Read recorded requests from MockWebServer.

**Expected:** Requests are POST `/keypress/VolumeUp`, POST `/keypress/Lit_H`, POST `/keypress/Lit_i`, and POST `/keypress/Lit_%21` in that order.

### 5. baseline parity (instrumented)

1. Launch DeviceDiscovery with a fake DiscoveryRepository that returns an empty saved-device list and no discovered devices after one scan window.
2. Wait until scanning completes.
3. Tap Refresh.
4. Have the fake repository return one Roku device named `Living Room Roku`.
5. Tap that device.

**Expected:** The first completed scan shows the empty state with Refresh and IR fallback actions; after refresh, `Living Room Roku` appears and tapping it routes to RemoteControl.

### 6. baseline parity (instrumented)

1. Launch IRFallback with a fake ConsumerIrManager wrapper configured with `hasIrEmitter=false`.
2. Verify the no-hardware state.
3. Relaunch IRFallback with the fake wrapper configured with `hasIrEmitter=true`.
4. Select the first bundled IR profile.
5. Tap Volume Down.

**Expected:** The no-hardware run disables transmit controls; the hardware-present run calls fake transmit exactly once with a non-empty microsecond pattern.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit -keypass changeit -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=TapOnce Remote,O=AppyFyi,C=US" || true
ANDROID_KEYSTORE_PATH=$PWD/release-upload.jks ANDROID_KEYSTORE_PASSWORD=changeit ANDROID_KEY_ALIAS=upload ANDROID_KEY_PASSWORD=changeit ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
