feat: build Sub2API monitor Android prototype

This commit is contained in:
Mimikko-zeus
2026-06-23 10:41:36 +08:00
parent 94c0cf8c73
commit 4bf3b0eb64
39 changed files with 1292 additions and 0 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.worktrees/
.learnings/

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# Sub2API Monitor
Android home-screen widget prototype for monitoring Sub2API usage and service status.
## Current Scope
- Native Android app in Kotlin.
- Jetpack Compose configuration screen.
- Jetpack Glance home-screen widget.
- Local configuration with DataStore.
- Mock Sub2API monitoring data.
- Manual widget refresh action.
- WorkManager refresh scheduling boundary.
- Last successful snapshot is preserved when refresh fails.
## Configuration
The app lets the user enter:
- Sub2API base URL.
- Admin key.
- Automatic refresh interval in minutes.
The admin key is stored locally through DataStore and is not rendered in the widget.
## Widget States
- Missing configuration: shows `请先配置 Sub2API`.
- Configured with data: shows dashboard metrics, recent calls, model TOP4, and lifetime totals.
- Refresh failure: stores the error while keeping the last successful snapshot visible.
## Replacing Mock Data
The first real integration should replace `MockSub2ApiRepository` with a network-backed implementation of `Sub2ApiRepository`. Keep the widget and Compose UI consuming `Sub2ApiSnapshot` so the rendering layer does not need structural changes.
## Build
Open this folder in Android Studio or run:
```powershell
.\gradlew :app:assembleDebug
```
This workspace currently does not include a Gradle wrapper jar. If Android Studio is used, let it sync the Gradle project and generate/use its configured Gradle installation.

49
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.sub2api.monitor"
compileSdk = 35
defaultConfig {
applicationId = "com.sub2api.monitor"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
debugImplementation("androidx.compose.ui:ui-tooling")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.Sub2ApiMonitor">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".widget.Sub2ApiMonitorWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/sub2api_monitor_widget" />
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,60 @@
package com.sub2api.monitor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.sub2api.monitor.data.DataStoreConfigRepository
import com.sub2api.monitor.data.MockSub2ApiRepository
import com.sub2api.monitor.sync.WorkManagerRefreshScheduler
import com.sub2api.monitor.ui.ConfigScreen
import com.sub2api.monitor.ui.ConfigViewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val configRepository = remember { DataStoreConfigRepository(applicationContext) }
val sub2ApiRepository = remember { MockSub2ApiRepository() }
val refreshScheduler = remember { WorkManagerRefreshScheduler(applicationContext) }
val configViewModel: ConfigViewModel = viewModel(
factory = ConfigViewModelFactory(
configRepository = configRepository,
sub2ApiRepository = sub2ApiRepository,
refreshScheduler = refreshScheduler,
),
)
Sub2ApiMonitorApp(configViewModel)
}
}
}
@Composable
fun Sub2ApiMonitorApp(configViewModel: ConfigViewModel) {
MaterialTheme {
ConfigScreen(
state = configViewModel.uiState,
onBaseUrlChange = configViewModel::updateBaseUrl,
onAdminKeyChange = configViewModel::updateAdminKey,
onRefreshIntervalChange = configViewModel::updateRefreshInterval,
onTestConnection = configViewModel::testConnection,
onSave = configViewModel::saveConfig,
)
}
}
private class ConfigViewModelFactory(
private val configRepository: DataStoreConfigRepository,
private val sub2ApiRepository: MockSub2ApiRepository,
private val refreshScheduler: WorkManagerRefreshScheduler,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConfigViewModel(configRepository, sub2ApiRepository, refreshScheduler) as T
}
}

View File

@@ -0,0 +1,10 @@
package com.sub2api.monitor.data
data class AppConfig(
val baseUrl: String = "",
val adminKey: String = "",
val refreshIntervalMinutes: Int = 30,
) {
val isConfigured: Boolean
get() = baseUrl.trim().isNotEmpty() && adminKey.trim().isNotEmpty()
}

View File

@@ -0,0 +1,8 @@
package com.sub2api.monitor.data
import kotlinx.coroutines.flow.Flow
interface ConfigRepository {
val config: Flow<AppConfig>
suspend fun save(config: AppConfig)
}

View File

@@ -0,0 +1,37 @@
package com.sub2api.monitor.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.configDataStore by preferencesDataStore(name = "sub2api_config")
class DataStoreConfigRepository(
private val context: Context,
) : ConfigRepository {
override val config: Flow<AppConfig> = context.configDataStore.data.map { preferences ->
AppConfig(
baseUrl = preferences[BASE_URL].orEmpty(),
adminKey = preferences[ADMIN_KEY].orEmpty(),
refreshIntervalMinutes = preferences[REFRESH_INTERVAL] ?: 30,
)
}
override suspend fun save(config: AppConfig) {
context.configDataStore.edit { preferences ->
preferences[BASE_URL] = config.baseUrl.trim()
preferences[ADMIN_KEY] = config.adminKey.trim()
preferences[REFRESH_INTERVAL] = config.refreshIntervalMinutes.coerceAtLeast(15)
}
}
private companion object {
val BASE_URL = stringPreferencesKey("base_url")
val ADMIN_KEY = stringPreferencesKey("admin_key")
val REFRESH_INTERVAL = intPreferencesKey("refresh_interval")
}
}

View File

@@ -0,0 +1,45 @@
package com.sub2api.monitor.data
import com.sub2api.monitor.domain.ModelUsage
import com.sub2api.monitor.domain.RecentCall
import com.sub2api.monitor.domain.ServiceStatus
import com.sub2api.monitor.domain.Sub2ApiSnapshot
class MockSub2ApiRepository : Sub2ApiRepository {
override suspend fun fetchSnapshot(): Sub2ApiSnapshot {
val now = System.currentTimeMillis()
return Sub2ApiSnapshot(
lastUpdatedAtMillis = now,
todayTokens = 1_284_920,
todayCost = 18.73,
todayRequests = 8_426,
averageLatencyMs = 248,
rpm = 42,
tpm = 18_500,
serviceStatus = ServiceStatus.Healthy,
activeKeyCount = 23,
userCount = 156,
recentCalls = listOf(
RecentCall("gpt-4.1-mini", 200, 214, 1_842, 0.012, now - 60_000),
RecentCall("gpt-4o", 200, 352, 4_126, 0.084, now - 180_000),
RecentCall("claude-sonnet-4", 200, 441, 6_220, 0.121, now - 260_000),
RecentCall("gpt-4.1", 429, 96, 0, 0.0, now - 330_000),
RecentCall("gemini-2.5-flash", 200, 188, 2_019, 0.009, now - 420_000),
),
modelTop = listOf(
ModelUsage("gpt-4o", 482_300, 7.82),
ModelUsage("claude-sonnet-4", 361_220, 6.13),
ModelUsage("gpt-4.1-mini", 288_140, 3.41),
ModelUsage("gemini-2.5-flash", 153_260, 1.37),
),
totalTokens = 92_518_400,
totalCost = 1_438.29,
)
}
override suspend fun testConnection(config: AppConfig): Result<Unit> {
return if (config.isConfigured) Result.success(Unit) else Result.failure(
IllegalStateException("Missing Sub2API URL or admin key"),
)
}
}

View File

@@ -0,0 +1,8 @@
package com.sub2api.monitor.data
import com.sub2api.monitor.domain.Sub2ApiSnapshot
interface Sub2ApiRepository {
suspend fun fetchSnapshot(): Sub2ApiSnapshot
suspend fun testConnection(config: AppConfig): Result<Unit>
}

View File

@@ -0,0 +1,29 @@
package com.sub2api.monitor.domain
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
fun formatTokens(value: Long): String = compactNumber(value)
fun formatCurrency(value: Double): String = "$" + String.format(Locale.US, "%.2f", value)
fun formatLatency(valueMs: Int): String = "$valueMs ms"
fun formatRpm(value: Int): String = "$value RPM"
fun formatTpm(value: Long): String = "${compactNumber(value)} TPM"
fun formatDisplayTime(valueMillis: Long): String {
val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
return formatter.format(Date(valueMillis))
}
private fun compactNumber(value: Long): String {
val abs = kotlin.math.abs(value)
return when {
abs >= 1_000_000 -> String.format(Locale.US, "%.1fM", value / 1_000_000.0)
abs >= 1_000 -> String.format(Locale.US, "%.1fK", value / 1_000.0)
else -> value.toString()
}
}

View File

@@ -0,0 +1,45 @@
package com.sub2api.monitor.domain
import kotlinx.serialization.Serializable
@Serializable
data class Sub2ApiSnapshot(
val lastUpdatedAtMillis: Long,
val todayTokens: Long,
val todayCost: Double,
val todayRequests: Int,
val averageLatencyMs: Int,
val rpm: Int,
val tpm: Long,
val serviceStatus: ServiceStatus,
val activeKeyCount: Int,
val userCount: Int,
val recentCalls: List<RecentCall>,
val modelTop: List<ModelUsage>,
val totalTokens: Long,
val totalCost: Double,
)
@Serializable
data class RecentCall(
val model: String,
val statusCode: Int,
val latencyMs: Int,
val tokens: Long,
val cost: Double,
val createdAtMillis: Long,
)
@Serializable
data class ModelUsage(
val model: String,
val tokens: Long,
val cost: Double,
)
@Serializable
enum class ServiceStatus {
Healthy,
Degraded,
Down,
}

View File

@@ -0,0 +1,7 @@
package com.sub2api.monitor.sync
interface RefreshScheduler {
fun schedule(intervalMinutes: Int)
}
fun sanitizeRefreshIntervalMinutes(intervalMinutes: Int): Int = intervalMinutes.coerceAtLeast(15)

View File

@@ -0,0 +1,41 @@
package com.sub2api.monitor.sync
import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.sub2api.monitor.data.DataStoreConfigRepository
import com.sub2api.monitor.data.MockSub2ApiRepository
import com.sub2api.monitor.widget.Sub2ApiMonitorWidget
import com.sub2api.monitor.widget.WidgetStateRepository
import kotlinx.coroutines.flow.first
class WidgetRefreshWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val configRepository = DataStoreConfigRepository(applicationContext)
val config = configRepository.config.first()
if (!config.isConfigured) {
Sub2ApiMonitorWidget().updateAll(applicationContext)
return Result.success()
}
val widgetStateRepository = WidgetStateRepository(applicationContext)
val repository = MockSub2ApiRepository()
return runCatching {
val snapshot = repository.fetchSnapshot()
widgetStateRepository.saveSuccess(snapshot)
Sub2ApiMonitorWidget().updateAll(applicationContext)
}.fold(
onSuccess = { Result.success() },
onFailure = { error ->
widgetStateRepository.saveError(error.message ?: "刷新失败")
Sub2ApiMonitorWidget().updateAll(applicationContext)
Result.retry()
},
)
}
}

View File

@@ -0,0 +1,31 @@
package com.sub2api.monitor.sync
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
class WorkManagerRefreshScheduler(
context: Context,
) : RefreshScheduler {
private val workManager = WorkManager.getInstance(context.applicationContext)
override fun schedule(intervalMinutes: Int) {
val sanitized = sanitizeRefreshIntervalMinutes(intervalMinutes)
val request = PeriodicWorkRequestBuilder<WidgetRefreshWorker>(
sanitized.toLong(),
TimeUnit.MINUTES,
).build()
workManager.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
private companion object {
const val WORK_NAME = "sub2api_widget_refresh"
}
}

View File

@@ -0,0 +1,149 @@
package com.sub2api.monitor.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun ConfigScreen(
state: ConfigUiState,
onBaseUrlChange: (String) -> Unit,
onAdminKeyChange: (String) -> Unit,
onRefreshIntervalChange: (String) -> Unit,
onTestConnection: () -> Unit,
onSave: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxSize(),
color = Color(0xFFE8EEF7),
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFFE8EEF7), Color(0xFFF7FAFC), Color(0xFFEFF6F2)),
),
)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Sub2API Monitor",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF172033),
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
OutlinedTextField(
value = state.baseUrl,
onValueChange = onBaseUrlChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Sub2API 地址") },
placeholder = { Text("https://sub2api.example.com") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
OutlinedTextField(
value = state.adminKey,
onValueChange = onAdminKeyChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Admin Key") },
visualTransformation = PasswordVisualTransformation(),
)
OutlinedTextField(
value = state.refreshIntervalMinutes,
onValueChange = onRefreshIntervalChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("自动刷新间隔(分钟)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
Row(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onTestConnection,
enabled = !state.isTesting,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB)),
) {
Text(if (state.isTesting) "测试中" else "测试连接")
}
Spacer(modifier = Modifier.width(12.dp))
Button(
onClick = onSave,
enabled = !state.isSaving,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0F766E)),
) {
Text(if (state.isSaving) "保存中" else "保存配置")
}
}
state.statusMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF0F766E),
)
}
}
}
}
}
}
@Composable
fun ConfigScreenPreviewState() {
var state by remember { mutableStateOf(ConfigUiState()) }
ConfigScreen(
state = state,
onBaseUrlChange = { state = state.withBaseUrl(it) },
onAdminKeyChange = { state = state.withAdminKey(it) },
onRefreshIntervalChange = { state = state.withRefreshInterval(it) },
onTestConnection = {},
onSave = {},
)
}

View File

@@ -0,0 +1,88 @@
package com.sub2api.monitor.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sub2api.monitor.data.AppConfig
import com.sub2api.monitor.data.ConfigRepository
import com.sub2api.monitor.data.Sub2ApiRepository
import com.sub2api.monitor.sync.RefreshScheduler
import kotlinx.coroutines.launch
data class ConfigUiState(
val baseUrl: String = "",
val adminKey: String = "",
val refreshIntervalMinutes: String = "30",
val isSaving: Boolean = false,
val isTesting: Boolean = false,
val statusMessage: String? = null,
) {
fun withBaseUrl(value: String): ConfigUiState = copy(baseUrl = value)
fun withAdminKey(value: String): ConfigUiState = copy(adminKey = value)
fun withRefreshInterval(value: String): ConfigUiState = copy(refreshIntervalMinutes = value.filter { it.isDigit() })
fun toAppConfig(): AppConfig = AppConfig(
baseUrl = baseUrl,
adminKey = adminKey,
refreshIntervalMinutes = refreshIntervalMinutes.toIntOrNull()?.coerceAtLeast(15) ?: 30,
)
}
class ConfigViewModel(
private val configRepository: ConfigRepository,
private val sub2ApiRepository: Sub2ApiRepository,
private val refreshScheduler: RefreshScheduler,
) : ViewModel() {
var uiState by mutableStateOf(ConfigUiState())
private set
init {
viewModelScope.launch {
configRepository.config.collect { config ->
uiState = uiState.copy(
baseUrl = config.baseUrl,
adminKey = config.adminKey,
refreshIntervalMinutes = config.refreshIntervalMinutes.toString(),
)
}
}
}
fun updateBaseUrl(value: String) {
uiState = uiState.withBaseUrl(value)
}
fun updateAdminKey(value: String) {
uiState = uiState.withAdminKey(value)
}
fun updateRefreshInterval(value: String) {
uiState = uiState.withRefreshInterval(value)
}
fun testConnection() {
viewModelScope.launch {
uiState = uiState.copy(isTesting = true, statusMessage = null)
val result = sub2ApiRepository.testConnection(uiState.toAppConfig())
uiState = uiState.copy(
isTesting = false,
statusMessage = result.fold(
onSuccess = { "连接测试成功" },
onFailure = { it.message ?: "连接测试失败" },
),
)
}
}
fun saveConfig() {
viewModelScope.launch {
uiState = uiState.copy(isSaving = true, statusMessage = null)
val config = uiState.toAppConfig()
configRepository.save(config)
refreshScheduler.schedule(config.refreshIntervalMinutes)
uiState = uiState.copy(isSaving = false, statusMessage = "配置已保存")
}
}
}

View File

@@ -0,0 +1,39 @@
package com.sub2api.monitor.widget
import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.ActionCallback
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.update
import com.sub2api.monitor.data.DataStoreConfigRepository
import com.sub2api.monitor.data.MockSub2ApiRepository
import kotlinx.coroutines.flow.first
class RefreshWidgetAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters,
) {
val config = DataStoreConfigRepository(context).config.first()
val widgetStateRepository = WidgetStateRepository(context)
if (!config.isConfigured) {
Sub2ApiMonitorWidget().update(context, glanceId)
return
}
runCatching {
MockSub2ApiRepository().fetchSnapshot()
}.fold(
onSuccess = { snapshot ->
widgetStateRepository.saveSuccess(snapshot)
},
onFailure = { error ->
widgetStateRepository.saveError(error.message ?: "刷新失败")
},
)
Sub2ApiMonitorWidget().update(context, glanceId)
}
}

View File

@@ -0,0 +1,255 @@
package com.sub2api.monitor.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.glance.Button
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.actionRunCallback
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.defaultWeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import com.sub2api.monitor.R
import com.sub2api.monitor.data.DataStoreConfigRepository
import com.sub2api.monitor.domain.ModelUsage
import com.sub2api.monitor.domain.RecentCall
import com.sub2api.monitor.domain.ServiceStatus
import com.sub2api.monitor.domain.formatCurrency
import com.sub2api.monitor.domain.formatDisplayTime
import com.sub2api.monitor.domain.formatLatency
import com.sub2api.monitor.domain.formatRpm
import com.sub2api.monitor.domain.formatTokens
import com.sub2api.monitor.domain.formatTpm
import kotlinx.coroutines.flow.first
class Sub2ApiMonitorWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val config = DataStoreConfigRepository(context).config.first()
val stateRepository = WidgetStateRepository(context)
val snapshot = stateRepository.lastSuccessfulSnapshot.first()
val error = stateRepository.lastError.first()
val state = buildWidgetState(config, snapshot, error)
provideContent {
WidgetContent(state)
}
}
}
@Composable
private fun WidgetContent(state: Sub2ApiWidgetState) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.background(ColorProvider(Color(0xCCFFFFFF)))
.cornerRadius(24.dp)
.padding(14.dp),
) {
if (!state.isConfigured) {
EmptyState()
} else {
DashboardState(state)
}
}
}
@Composable
private fun EmptyState() {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
provider = ImageProvider(R.drawable.ic_launcher),
contentDescription = null,
modifier = GlanceModifier.size(36.dp),
)
Spacer(modifier = GlanceModifier.height(8.dp))
Text(
text = "请先配置 Sub2API",
style = TextStyle(
color = ColorProvider(Color(0xFF172033)),
fontWeight = FontWeight.Bold,
),
)
}
}
@Composable
private fun DashboardState(state: Sub2ApiWidgetState) {
val snapshot = state.snapshot
if (snapshot == null) {
Column(
modifier = GlanceModifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("暂无监控数据", style = titleStyle())
Spacer(modifier = GlanceModifier.height(8.dp))
Button(text = "刷新", onClick = actionRunCallback<RefreshWidgetAction>())
}
return
}
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = GlanceModifier.defaultWeight()) {
Text("Sub2API", style = titleStyle())
Text(
text = "更新 ${formatDisplayTime(snapshot.lastUpdatedAtMillis)}",
style = mutedStyle(),
)
}
Button(text = "刷新", onClick = actionRunCallback<RefreshWidgetAction>())
}
state.errorMessage?.let {
Spacer(modifier = GlanceModifier.height(6.dp))
Text("错误:$it", style = errorStyle())
}
Spacer(modifier = GlanceModifier.height(8.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
MetricBlock("今日 Token", formatTokens(snapshot.todayTokens), Color(0xFF2563EB), GlanceModifier.defaultWeight())
Spacer(modifier = GlanceModifier.width(8.dp))
MetricBlock("今日消耗", formatCurrency(snapshot.todayCost), Color(0xFF0F766E), GlanceModifier.defaultWeight())
}
Spacer(modifier = GlanceModifier.height(8.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
MetricBlock("请求次数", snapshot.todayRequests.toString(), Color(0xFF7C3AED), GlanceModifier.defaultWeight())
Spacer(modifier = GlanceModifier.width(8.dp))
MetricBlock("服务状态", statusText(snapshot.serviceStatus), statusColor(snapshot.serviceStatus), GlanceModifier.defaultWeight())
}
Spacer(modifier = GlanceModifier.height(8.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
TinyMetric("平均", formatLatency(snapshot.averageLatencyMs), GlanceModifier.defaultWeight())
TinyMetric("RPM", formatRpm(snapshot.rpm), GlanceModifier.defaultWeight())
TinyMetric("TPM", formatTpm(snapshot.tpm), GlanceModifier.defaultWeight())
TinyMetric("Key", snapshot.activeKeyCount.toString(), GlanceModifier.defaultWeight())
TinyMetric("用户", snapshot.userCount.toString(), GlanceModifier.defaultWeight())
}
Spacer(modifier = GlanceModifier.height(8.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
Column(modifier = GlanceModifier.defaultWeight()) {
SectionTitle("最近调用")
snapshot.recentCalls.take(5).forEach { RecentCallRow(it) }
}
Spacer(modifier = GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
SectionTitle("模型 TOP4")
snapshot.modelTop.take(4).forEach { ModelUsageRow(it) }
}
}
Spacer(modifier = GlanceModifier.height(6.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
Text("累计 ${formatTokens(snapshot.totalTokens)}", style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
Text("累计 ${formatCurrency(snapshot.totalCost)}", style = mutedStyle())
}
}
}
@Composable
private fun MetricBlock(label: String, value: String, color: Color, modifier: GlanceModifier = GlanceModifier) {
Column(
modifier = modifier
.background(ColorProvider(Color(0x26FFFFFF)))
.cornerRadius(8.dp)
.padding(8.dp),
) {
Text(label, style = mutedStyle())
Text(
value,
style = TextStyle(
color = ColorProvider(color),
fontWeight = FontWeight.Bold,
),
)
}
}
@Composable
private fun TinyMetric(label: String, value: String, modifier: GlanceModifier = GlanceModifier) {
Column(modifier = modifier.padding(end = 4.dp)) {
Text(label, style = mutedStyle())
Text(value, style = smallStrongStyle())
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, style = smallStrongStyle())
Spacer(modifier = GlanceModifier.height(3.dp))
}
@Composable
private fun RecentCallRow(call: RecentCall) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
Text(call.model, style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
Text("${call.statusCode}", style = mutedStyle())
}
}
@Composable
private fun ModelUsageRow(model: ModelUsage) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
Text(model.model, style = mutedStyle(), modifier = GlanceModifier.defaultWeight())
Text(formatCurrency(model.cost), style = mutedStyle())
}
}
private fun titleStyle(): TextStyle = TextStyle(
color = ColorProvider(Color(0xFF172033)),
fontWeight = FontWeight.Bold,
)
private fun smallStrongStyle(): TextStyle = TextStyle(
color = ColorProvider(Color(0xFF172033)),
fontWeight = FontWeight.Bold,
)
private fun mutedStyle(): TextStyle = TextStyle(color = ColorProvider(Color(0xFF5F6B7A)))
private fun errorStyle(): TextStyle = TextStyle(
color = ColorProvider(Color(0xFFB42318)),
fontWeight = FontWeight.Bold,
)
private fun statusText(status: ServiceStatus): String = when (status) {
ServiceStatus.Healthy -> "正常"
ServiceStatus.Degraded -> "降级"
ServiceStatus.Down -> "异常"
}
private fun statusColor(status: ServiceStatus): Color = when (status) {
ServiceStatus.Healthy -> Color(0xFF0F766E)
ServiceStatus.Degraded -> Color(0xFFB45309)
ServiceStatus.Down -> Color(0xFFB42318)
}

View File

@@ -0,0 +1,8 @@
package com.sub2api.monitor.widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
class Sub2ApiMonitorWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = Sub2ApiMonitorWidget()
}

View File

@@ -0,0 +1,24 @@
package com.sub2api.monitor.widget
import com.sub2api.monitor.data.AppConfig
import com.sub2api.monitor.domain.Sub2ApiSnapshot
fun buildWidgetState(
config: AppConfig,
lastSuccessfulSnapshot: Sub2ApiSnapshot?,
errorMessage: String?,
): Sub2ApiWidgetState {
if (!config.isConfigured) {
return Sub2ApiWidgetState(
isConfigured = false,
snapshot = null,
errorMessage = null,
)
}
return Sub2ApiWidgetState(
isConfigured = true,
snapshot = lastSuccessfulSnapshot,
errorMessage = errorMessage,
)
}

View File

@@ -0,0 +1,12 @@
package com.sub2api.monitor.widget
import com.sub2api.monitor.domain.Sub2ApiSnapshot
data class Sub2ApiWidgetState(
val isConfigured: Boolean,
val snapshot: Sub2ApiSnapshot?,
val errorMessage: String?,
) {
val hasError: Boolean = errorMessage != null
val primaryMessage: String = if (isConfigured) "Sub2API" else "请先配置 Sub2API"
}

View File

@@ -0,0 +1,47 @@
package com.sub2api.monitor.widget
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.sub2api.monitor.domain.Sub2ApiSnapshot
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val Context.widgetStateDataStore by preferencesDataStore(name = "sub2api_widget_state")
class WidgetStateRepository(
private val context: Context,
private val json: Json = Json { ignoreUnknownKeys = true },
) {
val lastSuccessfulSnapshot: Flow<Sub2ApiSnapshot?> = context.widgetStateDataStore.data.map { preferences ->
preferences[SNAPSHOT]?.let { value ->
runCatching { json.decodeFromString<Sub2ApiSnapshot>(value) }.getOrNull()
}
}
val lastError: Flow<String?> = context.widgetStateDataStore.data.map { preferences ->
preferences[ERROR]
}
suspend fun saveSuccess(snapshot: Sub2ApiSnapshot) {
context.widgetStateDataStore.edit { preferences ->
preferences[SNAPSHOT] = json.encodeToString(snapshot)
preferences.remove(ERROR)
}
}
suspend fun saveError(message: String) {
context.widgetStateDataStore.edit { preferences ->
preferences[ERROR] = message
}
}
private companion object {
val SNAPSHOT = stringPreferencesKey("snapshot")
val ERROR = stringPreferencesKey("error")
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#F7FAFC"
android:pathData="M4,4h40v40h-40z" />
<path
android:fillColor="#2563EB"
android:pathData="M14,30h4v6h-4zM22,20h4v16h-4zM30,12h4v24h-4z" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="16dp"
android:text="@string/app_name"
android:textColor="#172033"
android:textStyle="bold" />

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Sub2API Monitor</string>
<string name="widget_description">Sub2API monitoring dashboard widget</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Sub2ApiMonitor" parent="android:style/Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/widget_loading"
android:minWidth="280dp"
android:minHeight="220dp"
android:previewImage="@drawable/ic_launcher"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="3"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />

View File

@@ -0,0 +1,11 @@
package com.sub2api.monitor
import org.junit.Assert.assertEquals
import org.junit.Test
class SmokeTest {
@Test
fun projectRunsUnitTests() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,23 @@
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)
}
}

View File

@@ -0,0 +1,18 @@
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)
}
}

View File

@@ -0,0 +1,24 @@
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))
}
}

View File

@@ -0,0 +1,12 @@
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))
}
}

View File

@@ -0,0 +1,18 @@
package com.sub2api.monitor.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class ConfigViewModelTest {
@Test
fun updatesFormFields() {
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)
}
}

View File

@@ -0,0 +1,31 @@
package com.sub2api.monitor.widget
import com.sub2api.monitor.data.AppConfig
import com.sub2api.monitor.data.MockSub2ApiRepository
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)
}
}

View File

@@ -0,0 +1,13 @@
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)
}
}

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Sub2API Monitor"
include(":app")