Files
sub2api-monitor/docs/plans/2026-06-23-sub2api-monitor-implementation.md
2026-06-23 10:18:53 +08:00

581 lines
16 KiB
Markdown

# Sub2API Monitor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a runnable native Android prototype with a Compose configuration screen and a Glance home-screen widget for monitoring Sub2API using mock data.
**Architecture:** Create a single-module Android app. Keep business state in small Kotlin domain models and repository interfaces, persist configuration and cached widget state through DataStore, render the app with Compose, and render the home-screen widget with Glance. The first data source is mock-only, but the repository boundary must allow a future real Sub2API admin client to replace it.
**Tech Stack:** Kotlin, Gradle Android plugin, Jetpack Compose, Jetpack Glance, DataStore, kotlinx.serialization, WorkManager boundary, JUnit.
---
### Task 1: Scaffold Android Project
**Files:**
- Create: `settings.gradle.kts`
- Create: `build.gradle.kts`
- Create: `gradle.properties`
- Create: `app/build.gradle.kts`
- Create: `app/src/main/AndroidManifest.xml`
- Create: `app/src/main/java/com/sub2api/monitor/MainActivity.kt`
- Create: `app/src/test/java/com/sub2api/monitor/SmokeTest.kt`
**Step 1: Write the failing test**
Create `app/src/test/java/com/sub2api/monitor/SmokeTest.kt`:
```kotlin
package com.sub2api.monitor
import org.junit.Assert.assertEquals
import org.junit.Test
class SmokeTest {
@Test
fun projectRunsUnitTests() {
assertEquals(4, 2 + 2)
}
}
```
**Step 2: Run test to verify project wiring**
Run: `./gradlew :app:testDebugUnitTest`
Expected before scaffolding is complete: command fails because no Android project or Gradle wrapper exists.
**Step 3: Write minimal implementation**
Create the Gradle Android project, Android manifest, and a minimal `MainActivity` that renders a Compose `Text("Sub2API Monitor")`.
Use package name:
```text
com.sub2api.monitor
```
Use minimum SDK 26 and target/compile SDK available in the local Android toolchain.
**Step 4: Run test to verify it passes**
Run: `./gradlew :app:testDebugUnitTest`
Expected: `BUILD SUCCESSFUL`.
**Step 5: Commit**
```bash
git add settings.gradle.kts build.gradle.kts gradle.properties app
git commit -m "chore: scaffold Android app"
```
---
### Task 2: Add Domain Models and Formatting
**Files:**
- Create: `app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt`
- Create: `app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt`
- Create: `app/src/test/java/com/sub2api/monitor/domain/MetricFormattersTest.kt`
**Step 1: Write the failing tests**
Create tests for formatting behavior:
```kotlin
package com.sub2api.monitor.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class MetricFormattersTest {
@Test
fun formatsLargeTokenCounts() {
assertEquals("12.3K", formatTokens(12_345))
assertEquals("9.9M", formatTokens(9_876_543))
}
@Test
fun formatsCurrencyWithDollarPrefix() {
assertEquals("$3.21", formatCurrency(3.214))
}
@Test
fun formatsLatencyAndRates() {
assertEquals("248 ms", formatLatency(248))
assertEquals("42 RPM", formatRpm(42))
assertEquals("18.5K TPM", formatTpm(18_500))
}
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests "*MetricFormattersTest"`
Expected: FAIL because formatter functions do not exist.
**Step 3: Write minimal implementation**
Create immutable domain data classes:
```kotlin
data class Sub2ApiSnapshot(...)
data class RecentCall(...)
data class ModelUsage(...)
enum class ServiceStatus { Healthy, Degraded, Down }
```
Add formatter functions for tokens, currency, latency, RPM, TPM, and display time.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests "*MetricFormattersTest"`
Expected: PASS.
**Step 5: Commit**
```bash
git add app/src/main/java/com/sub2api/monitor/domain app/src/test/java/com/sub2api/monitor/domain
git commit -m "feat: add monitoring domain models"
```
---
### Task 3: Add Configuration Storage
**Files:**
- Create: `app/src/main/java/com/sub2api/monitor/data/AppConfig.kt`
- Create: `app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt`
- Create: `app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt`
- Create: `app/src/test/java/com/sub2api/monitor/data/ConfigRepositoryTest.kt`
**Step 1: Write the failing tests**
Test pure configuration validation first:
```kotlin
package com.sub2api.monitor.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConfigRepositoryTest {
@Test
fun detectsMissingConfiguration() {
assertFalse(AppConfig().isConfigured)
}
@Test
fun detectsSavedConfiguration() {
val config = AppConfig(
baseUrl = "https://sub2api.example.com",
adminKey = "secret",
refreshIntervalMinutes = 30,
)
assertTrue(config.isConfigured)
}
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests "*ConfigRepositoryTest"`
Expected: FAIL because `AppConfig` does not exist.
**Step 3: Write minimal implementation**
Create `AppConfig` with defaults and `isConfigured`.
Create repository interface:
```kotlin
interface ConfigRepository {
val config: Flow<AppConfig>
suspend fun save(config: AppConfig)
}
```
Implement `DataStoreConfigRepository` with preferences DataStore. Keep admin key local and never expose it outside config/repository boundaries.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests "*ConfigRepositoryTest"`
Expected: PASS.
**Step 5: Commit**
```bash
git add app/src/main/java/com/sub2api/monitor/data app/src/test/java/com/sub2api/monitor/data
git commit -m "feat: add local configuration storage"
```
---
### Task 4: Add Mock Monitoring Repository
**Files:**
- Create: `app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt`
- Create: `app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt`
- Create: `app/src/test/java/com/sub2api/monitor/data/MockSub2ApiRepositoryTest.kt`
**Step 1: Write the failing tests**
```kotlin
package com.sub2api.monitor.data
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MockSub2ApiRepositoryTest {
@Test
fun returnsCompleteSnapshot() = runTest {
val snapshot = MockSub2ApiRepository().fetchSnapshot()
assertTrue(snapshot.todayTokens > 0)
assertTrue(snapshot.todayCost > 0.0)
assertEquals(5, snapshot.recentCalls.size)
assertEquals(4, snapshot.modelTop.size)
}
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests "*MockSub2ApiRepositoryTest"`
Expected: FAIL because repository does not exist.
**Step 3: Write minimal implementation**
Create:
```kotlin
interface Sub2ApiRepository {
suspend fun fetchSnapshot(): Sub2ApiSnapshot
suspend fun testConnection(config: AppConfig): Result<Unit>
}
```
Implement `MockSub2ApiRepository` returning realistic but deterministic data for the widget and app.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests "*MockSub2ApiRepositoryTest"`
Expected: PASS.
**Step 5: Commit**
```bash
git add app/src/main/java/com/sub2api/monitor/data app/src/test/java/com/sub2api/monitor/data
git commit -m "feat: add mock Sub2API repository"
```
---
### Task 5: Add Widget State and Refresh Behavior
**Files:**
- Create: `app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt`
- Create: `app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt`
- Create: `app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt`
- Create: `app/src/test/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModelTest.kt`
**Step 1: Write the failing tests**
```kotlin
package com.sub2api.monitor.widget
import com.sub2api.monitor.data.AppConfig
import com.sub2api.monitor.data.MockSub2ApiRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class Sub2ApiWidgetViewModelTest {
@Test
fun missingConfigShowsSetupPrompt() = runTest {
val state = buildWidgetState(AppConfig(), null, null)
assertFalse(state.isConfigured)
}
@Test
fun failedRefreshPreservesLastSuccessfulSnapshot() = runTest {
val previous = MockSub2ApiRepository().fetchSnapshot()
val state = buildWidgetState(
config = AppConfig("https://example.com", "secret", 30),
lastSuccessfulSnapshot = previous,
errorMessage = "Network error",
)
assertTrue(state.isConfigured)
assertNotNull(state.snapshot)
assertTrue(state.hasError)
}
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests "*Sub2ApiWidgetViewModelTest"`
Expected: FAIL because widget state functions do not exist.
**Step 3: Write minimal implementation**
Create widget state models:
```kotlin
data class Sub2ApiWidgetState(
val isConfigured: Boolean,
val snapshot: Sub2ApiSnapshot?,
val errorMessage: String?,
)
```
Add `buildWidgetState` to map config, last successful snapshot, and error into renderable state. Add repository methods for reading/writing last successful snapshot and last error.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests "*Sub2ApiWidgetViewModelTest"`
Expected: PASS.
**Step 5: Commit**
```bash
git add app/src/main/java/com/sub2api/monitor/widget app/src/test/java/com/sub2api/monitor/widget
git commit -m "feat: add widget state handling"
```
---
### Task 6: Build Compose Configuration Screen
**Files:**
- Create: `app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt`
- Create: `app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt`
- Modify: `app/src/main/java/com/sub2api/monitor/MainActivity.kt`
- Create: `app/src/test/java/com/sub2api/monitor/ui/ConfigViewModelTest.kt`
**Step 1: Write the failing tests**
Test ViewModel-level behavior:
```kotlin
package com.sub2api.monitor.ui
import com.sub2api.monitor.data.AppConfig
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class ConfigViewModelTest {
@Test
fun updatesFormFields() = runTest {
val state = ConfigUiState()
.withBaseUrl("https://sub2api.example.com")
.withAdminKey("secret")
.withRefreshInterval("30")
assertEquals("https://sub2api.example.com", state.baseUrl)
assertEquals("secret", state.adminKey)
assertEquals("30", state.refreshIntervalMinutes)
}
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests "*ConfigViewModelTest"`
Expected: FAIL because UI state does not exist.
**Step 3: Write minimal implementation**
Create a Compose screen with:
- Title `Sub2API Monitor`
- URL text field
- Password admin key field
- Refresh interval input
- Test connection button
- Save configuration button
- Status message area
Use white/soft dashboard styling consistent with the widget. Wire `MainActivity` to display `ConfigScreen`.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests "*ConfigViewModelTest"`
Expected: PASS.
**Step 5: Commit**
```bash
git add app/src/main/java/com/sub2api/monitor/ui app/src/main/java/com/sub2api/monitor/MainActivity.kt app/src/test/java/com/sub2api/monitor/ui
git commit -m "feat: add configuration screen"
```
---
### Task 7: Build Glance Widget UI
**Files:**
- Create: `app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt`
- Create: `app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt`
- Create: `app/src/main/res/xml/sub2api_monitor_widget.xml`
- Modify: `app/src/main/AndroidManifest.xml`
**Step 1: Write the failing test**
Add a JVM test for the widget content source:
```kotlin
package com.sub2api.monitor.widget
import com.sub2api.monitor.data.AppConfig
import org.junit.Assert.assertEquals
import org.junit.Test
class WidgetStateTextTest {
@Test
fun missingConfigUsesSetupPrompt() {
val state = buildWidgetState(AppConfig(), null, null)
assertEquals("请先配置 Sub2API", state.primaryMessage)
}
}
```
**Step 2: Run test to verify it fails**
Run: `./gradlew :app:testDebugUnitTest --tests "*WidgetStateTextTest"`
Expected: FAIL because `primaryMessage` does not exist.
**Step 3: Write minimal implementation**
Add `primaryMessage` to widget state and build the Glance widget:
- White translucent rounded card.
- Header with title, last update, refresh action.
- Metric blocks for today's usage, cost, requests, status.
- Secondary metrics.
- Recent calls list.
- Model TOP4 list.
- Lifetime totals.
- Error banner when last refresh failed.
Register widget receiver and widget provider XML in the manifest.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests "*WidgetStateTextTest"`
Expected: PASS.
**Step 5: Commit**
```bash
git add app/src/main/java/com/sub2api/monitor/widget app/src/main/res/xml/sub2api_monitor_widget.xml app/src/main/AndroidManifest.xml
git commit -m "feat: add Sub2API home screen widget"
```
---
### Task 8: Add Refresh Actions and Scheduler Boundary
**Files:**
- Create: `app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt`
- Create: `app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt`
- Create: `app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt`
- Modify: `app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt`
- Modify: `app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt`
- Create: `app/src/test/java/com/sub2api/monitor/sync/RefreshSchedulerTest.kt`
**Step 1: Write the failing tests**
```kotlin
package com.sub2api.monitor.sync
import org.junit.Assert.assertEquals
import org.junit.Test
class RefreshSchedulerTest {
@Test
fun clampsRefreshIntervalToWorkManagerMinimum() {
assertEquals(15, sanitizeRefreshIntervalMinutes(5))
assertEquals(30, sanitizeRefreshIntervalMinutes(30))
}
}
```
**Step 2: Run tests to verify they fail**
Run: `./gradlew :app:testDebugUnitTest --tests "*RefreshSchedulerTest"`
Expected: FAIL because scheduler helpers do not exist.
**Step 3: Write minimal implementation**
Add manual refresh action for the widget. Add a scheduler boundary with WorkManager-compatible interval clamping. When configuration is saved, enqueue or update the periodic refresh request.
For the prototype, the worker can call the mock repository and write cached widget state.
**Step 4: Run tests to verify they pass**
Run: `./gradlew :app:testDebugUnitTest --tests "*RefreshSchedulerTest"`
Expected: PASS.
**Step 5: Commit**
```bash
git add app/src/main/java/com/sub2api/monitor/sync app/src/main/java/com/sub2api/monitor/widget app/src/main/java/com/sub2api/monitor/ui app/src/test/java/com/sub2api/monitor/sync
git commit -m "feat: add widget refresh actions"
```
---
### Task 9: Final Verification
**Files:**
- Modify only if verification reveals issues.
**Step 1: Run unit tests**
Run: `./gradlew :app:testDebugUnitTest`
Expected: `BUILD SUCCESSFUL`.
**Step 2: Run Android build**
Run: `./gradlew :app:assembleDebug`
Expected: `BUILD SUCCESSFUL` and debug APK generated.
**Step 3: Inspect repository status**
Run: `git status --short`
Expected: no unintended files. Commit any intentional final fixes.
**Step 4: Report**
Summarize implemented features, verification commands, and any limitations such
as simulator/device testing not being run.