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

16 KiB

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:

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:

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

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:

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:

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

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:

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:

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

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

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:

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

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

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:

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

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:

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

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:

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

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

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

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.