# StormScope One — build spec

A one-price, ad-free animated radar app that includes hurricane tracking and never sends investor upsells or re-gates purchased weather layers.

## 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:** MyRadar Weather Radar Pro
- **Package id:** `com.acmeaom.android.myradarpro`
- **Google Play:** https://play.google.com/store/apps/details?id=com.acmeaom.android.myradarpro
- **appy.fyi report:** https://appy.fyi/report/com.acmeaom.android.myradarpro
- **Category:** Weather

## Overview

- **Working name:** StormScope One (trademark cleared: no)
- **Package id:** `fyi.appy.stormscopeone`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 8 weeks
- **Pricing:** one-time purchase, $4.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `INTERNET`, `ACCESS_FINE_LOCATION`, `POST_NOTIFICATIONS`

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

- No investor prompts, crowdfunding prompts, stockholder/community features, or non-weather marketing notifications.
- No subscriptions, add-on purchases, credits, trials, or separate hurricane paywall in v1.
- No custom weather-data infrastructure; v1 consumes public/commercial weather APIs such as RainViewer, weather.gov, and NOAA/NHC endpoints.
- No social accounts, app-owned cloud sync, or user-generated content.
- No runtime AI weather summaries in v1 because the report says AI is not core to the product.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Android Kotlin extensions used by the app shell and permission handling | `androidx.core:core-ktx:1.13.1` |
| Compose activity host for MainActivity | `androidx.activity:activity-compose:1.9.3` |
| Compose UI runtime | `androidx.compose.ui:ui:1.7.4` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose preview tooling for debug builds | `androidx.compose.ui:ui-tooling-preview:1.7.4` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware state collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| OpenStreetMap-based map view and tile overlays embedded in Compose via AndroidView | `org.osmdroid:osmdroid-android:6.1.20` |
| Current device location for centering radar and querying weather alerts | `com.google.android.gms:play-services-location:21.3.0` |
| HTTP client for RainViewer, weather.gov, and NOAA/NHC API calls | `com.squareup.okhttp3:okhttp:4.12.0` |
| JSON parsing for weather API responses | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Periodic alert polling and widget radar image refresh | `androidx.work:work-runtime-ktx:2.9.1` |
| Google Play one-time in-app purchase and restore flow | `com.android.billingclient:billing-ktx:7.1.1` |
| Local persistence for settings, entitlement cache, alert subscriptions, and hurricane cache | `androidx.room:room-runtime:2.6.1` |
| Coroutine support for Room DAO calls | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor for generated DAO/database code | `androidx.room:room-compiler:2.6.1` |
| Home screen app widget UI | `androidx.glance:glance-appwidget:1.1.1` |
| Unit test runner | `junit:junit:4.13.2` |
| AndroidX instrumented test JUnit integration | `androidx.test.ext:junit:1.2.1` |
| Compose instrumented UI testing | `androidx.compose.ui:ui-test-junit4:1.7.4` |
| Mock HTTP server for deterministic RainViewer, weather.gov, and NOAA/NHC tests | `com.squareup.okhttp3:mockwebserver:4.12.0` |

## Design system

- **Primary color:** `#146C94`
- **Background color:** `#07131F`
- **Error color:** `#D32F2F`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `cloud-lightning` (regular weight)
- **Theme notes:** Dark theme is the default because radar imagery is easier to read on a dark map; light theme is supported by switching background to #F6FAFD and text to Material 3 onSurface, while keeping #146C94 as primary. Radar overlays use green/yellow/red API tile colors without recoloring. Purchase and trust messaging uses calm blue surfaces, never urgent marketing banners.

## Screens

### Unlock
- **Route:** `unlock`
- **Purpose:** Explain the single $4.99 unlock, start Google Play purchase, and restore previous purchases after reinstall or device change.
- **Reached via:** app launch when no unlocked entitlement is locally cached or restored from Google Play; tap Unlock from Radar; tap Restore purchases from Settings
- **Key UI elements:** One-time price card showing $4.99; Bulleted promise: no subscriptions, no investor upsells, hurricane tracking included; Buy once button; Restore purchase button; Entitlement status text; Error banner for billing failures
- **States:** loading_billing_client, locked, purchase_in_progress, unlocked, restore_in_progress, restore_failed, billing_unavailable

### Radar
- **Route:** `radar`
- **Purpose:** Main animated radar map centered on the user's location or a default national view.
- **Reached via:** app launch after entitlement check; tap Open radar from widget; system back from Layers, Alerts, Storm Details, or Settings
- **Key UI elements:** OSMDroid map; animated RainViewer radar tile overlay; current location marker; play/pause animation control; frame timestamp label; Layers button; Alerts button; Settings button; Unlock banner when entitlement is locked
- **States:** requesting_location_permission, location_denied_default_map, loading_radar_frames, empty_no_radar_frames, radar_api_error, populated_animating, populated_paused, locked_preview

### Layers
- **Route:** `layers`
- **Purpose:** Choose visible weather layers without exposing separate add-on paywalls.
- **Reached via:** tap Layers on Radar
- **Key UI elements:** Radar layer toggle; Weather alerts polygon toggle; Hurricane storm marker toggle; Radar opacity slider; Animation speed selector; Apply button
- **States:** loading_saved_settings, populated, save_error

### Alerts
- **Route:** `alerts`
- **Purpose:** Show active NOAA/weather.gov alerts for the selected location and manage weather alert notifications.
- **Reached via:** tap Alerts on Radar; tap a weather alert notification
- **Key UI elements:** Current location summary; Active alerts list; Alert severity chips; Enable weather notifications switch; Request notification permission button; Last checked timestamp; Manual refresh button
- **States:** permission_needed_location, permission_needed_notifications, loading_alerts, empty_no_active_alerts, alerts_api_error, populated, notifications_disabled, notifications_enabled

### Storm Details
- **Route:** `hurricane/{stormId}`
- **Purpose:** Show included hurricane tracking details for an active NOAA/NHC storm.
- **Reached via:** tap hurricane marker on Radar; tap hurricane item from Layers when hurricane layer is enabled
- **Key UI elements:** Storm name and classification; Current center latitude/longitude; Maximum wind and pressure fields when available; Movement/advisory summary; Open public advisory link button; Back to map button
- **States:** loading_storm, storm_not_found, nhc_api_error, populated

### Settings
- **Route:** `settings`
- **Purpose:** Give the user purchase restore controls, data-source attribution, privacy promises, and notification/channel settings.
- **Reached via:** tap Settings on Radar; tap Settings from Unlock
- **Key UI elements:** Entitlement status card; Restore purchase button; No investor upsells promise card; Weather data attribution for RainViewer, weather.gov, and NOAA/NHC; Location permission status; Notification permission status; Privacy policy link
- **States:** loading_settings, populated_unlocked, populated_locked, restore_in_progress, restore_success, restore_failed

### Widget Configure
- **Route:** `widget-config/{appWidgetId}`
- **Purpose:** Configure the home screen radar widget location and refresh behavior.
- **Reached via:** Android launcher adds StormScope One widget
- **Key UI elements:** Use current location button; Default national view option; Refresh interval explanation: Android schedules approximately every 30 minutes or when opened; Save widget button; Cancel button
- **States:** loading_widget_settings, permission_needed_location, ready_to_save, save_error, saved

## Data model

### AppSettings (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 for singleton settings row |
| lastLatitude | `Double?` | nullable; last successful location used for radar and alerts |
| lastLongitude | `Double?` | nullable; last successful location used for radar and alerts |
| followDeviceLocation | `Boolean` | true when the radar should recenter after a fresh location fix |
| radarOpacity | `Float` | 0.0 to 1.0; default 0.75 |
| animationFrameDelayMillis | `Int` | default 700 |
| showAlertsLayer | `Boolean` | default true |
| showHurricaneLayer | `Boolean` | default true |
| notificationsEnabled | `Boolean` | user-controlled weather alert notification switch; default false |

### EntitlementCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 for singleton entitlement row |
| productId | `String` | must be stormscope_one_unlock for the only v1 paid product |
| isUnlocked | `Boolean` | true only after Google Play Billing reports PURCHASED and the purchase is acknowledged |
| latestPurchaseTokenHash | `String?` | nullable SHA-256 hash of latest purchase token; never store raw token in logs |
| lastVerifiedAtEpochMillis | `Long` | wall-clock time when BillingClient last confirmed entitlement |
| verificationSource | `String` | one of purchase_flow, restore_query, launch_query, cached_grace |

### AlertSubscription (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 for singleton alert subscription row |
| latitude | `Double?` | nullable until location permission is granted or user opens radar with default location |
| longitude | `Double?` | nullable until location permission is granted or user opens radar with default location |
| lastSeenAlertIdsCsv | `String` | comma-separated weather.gov feature ids already notified |
| lastCheckedAtEpochMillis | `Long` | 0 when never checked |
| notificationsEnabled | `Boolean` | mirrors AppSettings.notificationsEnabled for worker access |

### HurricaneStormCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| stormId | `String` | primary key from NOAA/NHC active storm identifier |
| name | `String` | storm name from NOAA/NHC feed |
| basin | `String` | Atlantic, Eastern Pacific, Central Pacific, or feed-provided basin label |
| classification | `String` | feed-provided classification such as Tropical Storm or Hurricane |
| latitude | `Double?` | nullable if NOAA/NHC feed omits current center |
| longitude | `Double?` | nullable if NOAA/NHC feed omits current center |
| maxWindMph | `Int?` | nullable if unavailable |
| pressureMb | `Int?` | nullable if unavailable |
| publicAdvisoryUrl | `String?` | nullable URL to NOAA/NHC public advisory |
| updatedAtEpochMillis | `Long` | time this cache row was fetched |

## Features

### Animated RainViewer radar map with location

Displays animated radar tiles over a map centered on the user's current location, with a usable default view when location is denied.

- **Answers complaint:** baseline parity

- **Screens:** Radar, Layers

- **Estimated hours:** 112

**Implementation notes:** Use FusedLocationProviderClient.getCurrentLocation with PRIORITY_BALANCED_POWER_ACCURACY after ACCESS_FINE_LOCATION is granted. If permission is denied, center the OSMDroid MapView on latitude 39.8283, longitude -98.5795 at zoom 4. Fetch https://api.rainviewer.com/public/weather-maps.json with OkHttp, parse the host plus radar.past and radar.nowcast frame paths using kotlinx.serialization, sort frames by time ascending, and create an OSMDroid tile overlay whose URL template is {host}{path}/256/{z}/{x}/{y}/2/1_1.png. In Compose, host the MapView through AndroidView and run a coroutine tied to lifecycle STARTED that swaps the active radar overlay every AppSettings.animationFrameDelayMillis when play mode is active. If the frame list is empty, keep the base map visible and show an empty-state message instead of crashing.

**Acceptance criteria:**
- When MockWebServer returns three RainViewer frames, the Radar screen cycles through all three frame timestamps in ascending order while play mode is active.
- When location permission is denied, Radar opens centered on 39.8283,-98.5795 and shows the location_denied_default_map state.
- When RainViewer returns an empty radar.past and radar.nowcast list, Radar shows empty_no_radar_frames and leaves the map usable.
- Changing opacity in Layers updates the radar overlay alpha on Radar and persists after app restart.

### NOAA/weather.gov alert overlays and weather notifications

Shows active watch/warning polygons on the map and sends only user-enabled weather alert notifications.

- **Answers complaint:** baseline parity

- **Screens:** Radar, Alerts, Settings

- **Estimated hours:** 64

**Implementation notes:** For the active location, call https://api.weather.gov/alerts/active?point={lat},{lon} with a User-Agent header formatted as StormScopeOne/1.0 support@example.com. Parse GeoJSON features, read properties.event, properties.severity, properties.headline, properties.effective, properties.expires, and geometry coordinates. Draw Polygon overlays on OSMDroid with stroke/fill colors by severity: Extreme #B71C1C, Severe #EF6C00, Moderate #FDD835, Minor #43A047. Create a WeatherAlertWorker using WorkManager PeriodicWorkRequestBuilder with 15 minutes as the repeat interval; the worker exits immediately unless AlertSubscription.notificationsEnabled is true and POST_NOTIFICATIONS is granted on Android 13+. Store notified weather.gov feature ids in AlertSubscription.lastSeenAlertIdsCsv and only notify ids not previously seen. Create exactly one notification channel with id weather_alerts and send notifications with title equal to event and body equal to headline.

**Acceptance criteria:**
- A mocked Severe Thunderstorm Warning GeoJSON polygon appears on Radar when the alerts layer is enabled.
- With notifications disabled, WeatherAlertWorker performs the API request but posts zero notifications.
- With notifications enabled and POST_NOTIFICATIONS granted, a new weather.gov feature id produces one notification on channel weather_alerts.
- Running WeatherAlertWorker twice with the same feature id posts exactly one notification total.

### Included hurricane tracking with no separate paywall

Displays active NOAA/NHC storm markers and detail cards as part of the single unlock instead of selling hurricane tracking as an add-on.

- **Answers complaint:** Perpetual purchases re-gated

- **Screens:** Radar, Layers, Storm Details, Unlock

- **Estimated hours:** 36

**Implementation notes:** Fetch https://www.nhc.noaa.gov/CurrentStorms.json with OkHttp when the hurricane layer is enabled or Storm Details is opened. Parse active storm entries into HurricaneStormCache using the feed's storm identifier, name, basin, classification, center latitude/longitude when present, max wind, pressure, and public advisory URL when present. Draw a storm marker on Radar for every cached storm with non-null latitude and longitude; tapping a marker navigates to hurricane/{stormId}. The billing layer must expose only one product id, stormscope_one_unlock; there must be no separate product id, subscription product, remote flag, or UI branch that locks hurricane markers/details independently from the main unlock. If NOAA/NHC reports no active storms, show a clear empty state saying no active tracked storms.

**Acceptance criteria:**
- The product catalog requested from BillingClient contains stormscope_one_unlock and no hurricane-specific product id.
- After one successful unlock, the hurricane layer toggle is enabled without another purchase prompt.
- When a mocked NOAA/NHC response contains one active storm with coordinates, Radar shows one hurricane marker and Storm Details shows its name/classification.
- When the NOAA/NHC response has no active storms, Storm Details is not reachable from Radar and the hurricane layer shows an empty no-active-storms message.

### One-time purchase, ad-free trust promise, and no investor upsells

Unlocks the full app with one $4.99 Google Play in-app purchase and deliberately omits ads, subscriptions, investor prompts, and non-weather marketing notifications.

- **Answers complaint:** Investor upsell spam

- **Screens:** Unlock, Radar, Settings

- **Estimated hours:** 48

**Implementation notes:** Integrate BillingClient for a single INAPP product id stormscope_one_unlock priced in Play Console at $4.99. On Unlock, call queryProductDetailsAsync for that product id only, launchBillingFlow on Buy once, and acknowledge purchases immediately after PURCHASED if not already acknowledged. Store entitlement in EntitlementCache only after acknowledgment succeeds. Do not add any ad SDK. Do not create any notification channel other than weather_alerts. Keep all purchase copy fixed in local string resources: 'Buy once. No subscriptions. Hurricane tracking included. No investor upsells.' The app must not contain screens, dialogs, banners, push notifications, deep links, or local notifications for investor offers or non-weather marketing.

**Acceptance criteria:**
- Static inspection of billing product constants finds exactly one product id: stormscope_one_unlock.
- A successful purchase changes EntitlementCache.isUnlocked to true only after acknowledgePurchase returns OK.
- The app creates no notification channel except weather_alerts during a full navigation smoke test.
- No ad SDK dependency is present in the Gradle dependency graph.
- Unlock and Settings both display the no-subscriptions and no-investor-upsells promise text.

### Bulletproof Google Play purchase restore

Restores the one-time unlock after reinstall or device change using the user's Google Play purchase state rather than any device-local identifier.

- **Answers complaint:** Broken purchase restore

- **Screens:** Unlock, Settings, Radar

- **Estimated hours:** 32

**Implementation notes:** On every cold launch, after BillingClient reaches BillingResponseCode.OK, call queryPurchasesAsync with ProductType.INAPP and scan purchases for product stormscope_one_unlock with purchaseState PURCHASED. If found, acknowledge if needed and write EntitlementCache(isUnlocked=true, verificationSource='launch_query'). The Restore purchase button performs the same query and also calls queryPurchaseHistoryAsync(ProductType.INAPP) only for diagnostics/error messaging; entitlement is granted from active purchases, not from raw history alone. Do not use Android ID, device serial, Firebase Installation ID, or an app account as the entitlement key. If BillingClient is temporarily unavailable but the local cache was verified within the last 7 days, allow unlocked access with verificationSource='cached_grace' and show 'Last verified on {date}' in Settings; otherwise show the locked state with a restore error.

**Acceptance criteria:**
- After deleting all app data, a mocked BillingClient active purchase for stormscope_one_unlock unlocks the app on cold launch without pressing Buy.
- Pressing Restore with a mocked active purchase sets EntitlementCache.isUnlocked to true and navigates back to Radar.
- Pressing Restore with no active purchase keeps the app locked and shows restore_failed without deleting settings.
- No entitlement code reads Settings.Secure.ANDROID_ID or any hardware/device identifier.
- If the cached entitlement is 8 days old and BillingClient is unavailable, Radar remains locked and Settings shows restore_failed.

### Home screen radar widget

Provides a simple radar snapshot widget that opens the app and refreshes from the same public radar source.

- **Answers complaint:** baseline parity

- **Screens:** Widget Configure, Radar

- **Estimated hours:** 28

**Implementation notes:** Implement a GlanceAppWidget with small and medium layouts. Widget Configure stores either the last known location or the default national center. A RadarWidgetRefreshWorker runs with a 30-minute periodic request and, for the widget location, fetches the latest RainViewer frame from weather-maps.json, converts latitude/longitude to Web Mercator tile x/y at zoom 6, downloads a 2x2 block of 256px RainViewer PNG tiles around that tile using the same URL template as Radar, stitches them into a 512x512 Bitmap, saves it to internal storage as widget_radar_{appWidgetId}.png, and updates the widget ImageProvider. The widget footer shows the frame time and opens route radar when tapped. If fetching fails, keep the previous bitmap and update footer text to 'Last radar update failed'.

**Acceptance criteria:**
- Adding the widget opens Widget Configure and saving creates a Glance widget with a radar image area and timestamp footer.
- With mocked RainViewer JSON and four PNG tile responses, RadarWidgetRefreshWorker writes a 512x512 PNG file and updates the widget state with its path.
- If the worker receives HTTP 500, the existing widget image path is retained and the footer state becomes Last radar update failed.
- Tapping the widget launches MainActivity and navigates to Radar.

## Store listing

- **Title:** StormScope One
- **Short description:** One-price animated radar with hurricane tracking and no upsells.
- **Category:** Weather
- **Keywords:** weather radar, animated radar, hurricane tracker, weather alerts, storm radar, ad free radar, NOAA alerts, rain radar
- **Icon prompt:** Create a square Android app icon for a premium weather radar app named StormScope One: dark navy background (#07131F), a clean blue radar sweep circle in #146C94, a small white lightning cloud glyph centered, subtle green/yellow/red radar arcs behind it, flat vector style, high contrast, no text, no stock-market or investor imagery.

**Long description:**

StormScope One is a simple animated radar app built around a clear promise: buy once, get the weather layers in v1, and never receive investor upsells or surprise hurricane add-on paywalls.

What v1 includes:
• Animated radar map powered by public/commercial radar data
• Current-location radar view with a national fallback
• NOAA/weather.gov watches and warnings on the map
• Optional weather alert notifications only
• NOAA/NHC active hurricane tracking included in the same unlock
• Google Play purchase restore designed for reinstall and device-change flows
• Home screen radar snapshot widget

No subscriptions. No ads. No investor notifications. No separate hurricane tracking purchase.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://stormscope-one.example.com/privacy (privacy claims verified: no)
- **Data collected:** Precise location used to center radar and query weather.gov alerts when the user grants location permission; Google Play purchase status for the one-time unlock, stored locally as entitlement state; Weather alert ids stored locally to avoid duplicate notifications

## Test plan

### 1. Investor upsell spam (instrumented)

1. Install a debug build with a fake BillingClient returning an unlocked stormscope_one_unlock purchase.
2. Launch the app and navigate through Unlock, Radar, Layers, Alerts, Storm Details with mocked storm data, and Settings.
3. Use NotificationManager.getNotificationChannels() from the test process after navigation.
4. Search all visible Compose text nodes for the case-insensitive substrings investor, invest, shareholder, stock, funding, and IPO.

**Expected:** The only notification channel id is weather_alerts, and no visible text contains investor, invest, shareholder, stock, funding, or IPO.

### 2. Perpetual purchases re-gated (unit)

1. Instantiate the billing product catalog provider used by the app.
2. Read the full list of product ids exposed to UI and BillingClient.
3. Create an unlocked EntitlementCache for productId stormscope_one_unlock.
4. Render the Layers state reducer with showHurricaneLayer=true and the unlocked cache.

**Expected:** The catalog contains exactly [stormscope_one_unlock], and the hurricane layer is available without any hurricane-specific product id or subscription state.

### 3. Broken purchase restore (instrumented)

1. Clear app data before the test starts.
2. Configure FakeBillingClient to return one active INAPP purchase with product id stormscope_one_unlock, purchaseState PURCHASED, and acknowledged=true.
3. Launch MainActivity directly to app start.
4. Wait until the entitlement check completes and the navigation route settles.

**Expected:** The app navigates to Radar instead of Unlock, EntitlementCache.isUnlocked is true, and verificationSource is launch_query.

### 4. Broken purchase restore (instrumented)

1. Clear app data before the test starts.
2. Configure FakeBillingClient to return no active purchases and BillingResponseCode.OK.
3. Launch MainActivity.
4. On Unlock, tap Restore purchase.
5. Wait for restore_in_progress to disappear.

**Expected:** Unlock remains visible, restore_failed is shown, and EntitlementCache.isUnlocked is false.

### 5. baseline parity (unit)

1. Start MockWebServer with /public/weather-maps.json returning a RainViewer JSON body containing three radar frames with times 100, 200, and 300 and valid paths.
2. Create the RadarRepository pointed at the mock server host.
3. Call loadFrames().
4. Collect the returned frame list.

**Expected:** The repository returns three frames sorted by time [100, 200, 300] with tile URL templates ending in /256/{z}/{x}/{y}/2/1_1.png.

### 6. baseline parity (instrumented)

1. Grant ACCESS_FINE_LOCATION in the test environment, or inject a fake location provider returning latitude 35.0 and longitude -97.0.
2. Mock weather.gov /alerts/active?point=35.0,-97.0 to return one Severe Thunderstorm Warning polygon with feature id urn:test:warning1.
3. Launch Radar with alerts layer enabled.
4. Wait for the API response to be rendered.

**Expected:** Radar contains one alert polygon overlay and Alerts screen lists Severe Thunderstorm Warning with the mocked headline.

### 7. baseline parity (unit)

1. Create an AlertSubscription with notificationsEnabled=true and lastSeenAlertIdsCsv empty.
2. Mock weather.gov to return one feature id urn:test:warning1.
3. Run WeatherAlertWorker once with a fake notification dispatcher.
4. Run the same worker a second time against the same mocked response.

**Expected:** The fake dispatcher records exactly one notification total, and AlertSubscription.lastSeenAlertIdsCsv contains urn:test:warning1.

### 8. baseline parity (unit)

1. Mock RainViewer weather-maps.json to return one latest frame path.
2. Mock four tile PNG responses for the 2x2 tile block at zoom 6 around latitude 39.8283 and longitude -98.5795.
3. Run RadarWidgetRefreshWorker for appWidgetId 42.
4. Read the generated file widget_radar_42.png from internal storage.

**Expected:** The generated PNG exists, decodes successfully, and has width 512 and height 512.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
rm -f release.keystore
keytool -genkeypair -v -keystore release.keystore -storepass changeit123 -keypass changeit123 -alias release -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=StormScope One,O=Solo Builder,L=Internet,S=NA,C=US"
KEYSTORE_PATH=$PWD/release.keystore KEYSTORE_PASSWORD=changeit123 KEY_ALIAS=release KEY_PASSWORD=changeit123 ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
