From 4bf3b0eb648e430ec3ebfaae83a7c6e939c86135 Mon Sep 17 00:00:00 2001 From: Mimikko-zeus Date: Tue, 23 Jun 2026 10:41:36 +0800 Subject: [PATCH] feat: build Sub2API monitor Android prototype --- .gitignore | 1 + README.md | 44 +++ app/build.gradle.kts | 49 ++++ app/src/main/AndroidManifest.xml | 34 +++ .../java/com/sub2api/monitor/MainActivity.kt | 60 +++++ .../com/sub2api/monitor/data/AppConfig.kt | 10 + .../sub2api/monitor/data/ConfigRepository.kt | 8 + .../monitor/data/DataStoreConfigRepository.kt | 37 +++ .../monitor/data/MockSub2ApiRepository.kt | 45 ++++ .../sub2api/monitor/data/Sub2ApiRepository.kt | 8 + .../monitor/domain/MetricFormatters.kt | 29 ++ .../sub2api/monitor/domain/Sub2ApiModels.kt | 45 ++++ .../sub2api/monitor/sync/RefreshScheduler.kt | 7 + .../monitor/sync/WidgetRefreshWorker.kt | 41 +++ .../sync/WorkManagerRefreshScheduler.kt | 31 +++ .../com/sub2api/monitor/ui/ConfigScreen.kt | 149 ++++++++++ .../com/sub2api/monitor/ui/ConfigViewModel.kt | 88 ++++++ .../monitor/widget/RefreshWidgetAction.kt | 39 +++ .../monitor/widget/Sub2ApiMonitorWidget.kt | 255 ++++++++++++++++++ .../widget/Sub2ApiMonitorWidgetReceiver.kt | 8 + .../monitor/widget/Sub2ApiWidgetViewModel.kt | 24 ++ .../com/sub2api/monitor/widget/WidgetState.kt | 12 + .../monitor/widget/WidgetStateRepository.kt | 47 ++++ app/src/main/res/drawable/ic_launcher.xml | 13 + app/src/main/res/layout/widget_loading.xml | 9 + app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/styles.xml | 4 + .../main/res/xml/sub2api_monitor_widget.xml | 12 + .../java/com/sub2api/monitor/SmokeTest.kt | 11 + .../monitor/data/ConfigRepositoryTest.kt | 23 ++ .../monitor/data/MockSub2ApiRepositoryTest.kt | 18 ++ .../monitor/domain/MetricFormattersTest.kt | 24 ++ .../monitor/sync/RefreshSchedulerTest.kt | 12 + .../sub2api/monitor/ui/ConfigViewModelTest.kt | 18 ++ .../widget/Sub2ApiWidgetViewModelTest.kt | 31 +++ .../monitor/widget/WidgetStateTextTest.kt | 13 + build.gradle.kts | 6 + gradle.properties | 4 + settings.gradle.kts | 18 ++ 39 files changed, 1292 insertions(+) create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/sub2api/monitor/MainActivity.kt create mode 100644 app/src/main/java/com/sub2api/monitor/data/AppConfig.kt create mode 100644 app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt create mode 100644 app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt create mode 100644 app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt create mode 100644 app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt create mode 100644 app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt create mode 100644 app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt create mode 100644 app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt create mode 100644 app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt create mode 100644 app/src/main/java/com/sub2api/monitor/sync/WorkManagerRefreshScheduler.kt create mode 100644 app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt create mode 100644 app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt create mode 100644 app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt create mode 100644 app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt create mode 100644 app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt create mode 100644 app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt create mode 100644 app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt create mode 100644 app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt create mode 100644 app/src/main/res/drawable/ic_launcher.xml create mode 100644 app/src/main/res/layout/widget_loading.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/sub2api_monitor_widget.xml create mode 100644 app/src/test/java/com/sub2api/monitor/SmokeTest.kt create mode 100644 app/src/test/java/com/sub2api/monitor/data/ConfigRepositoryTest.kt create mode 100644 app/src/test/java/com/sub2api/monitor/data/MockSub2ApiRepositoryTest.kt create mode 100644 app/src/test/java/com/sub2api/monitor/domain/MetricFormattersTest.kt create mode 100644 app/src/test/java/com/sub2api/monitor/sync/RefreshSchedulerTest.kt create mode 100644 app/src/test/java/com/sub2api/monitor/ui/ConfigViewModelTest.kt create mode 100644 app/src/test/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModelTest.kt create mode 100644 app/src/test/java/com/sub2api/monitor/widget/WidgetStateTextTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore index e458ed5..2f81833 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .worktrees/ +.learnings/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f73f9b --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..16527d9 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..266228a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/sub2api/monitor/MainActivity.kt b/app/src/main/java/com/sub2api/monitor/MainActivity.kt new file mode 100644 index 0000000..32da817 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/MainActivity.kt @@ -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 create(modelClass: Class): T { + return ConfigViewModel(configRepository, sub2ApiRepository, refreshScheduler) as T + } +} diff --git a/app/src/main/java/com/sub2api/monitor/data/AppConfig.kt b/app/src/main/java/com/sub2api/monitor/data/AppConfig.kt new file mode 100644 index 0000000..3a42f5f --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/data/AppConfig.kt @@ -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() +} diff --git a/app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt b/app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt new file mode 100644 index 0000000..d5e07d7 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/data/ConfigRepository.kt @@ -0,0 +1,8 @@ +package com.sub2api.monitor.data + +import kotlinx.coroutines.flow.Flow + +interface ConfigRepository { + val config: Flow + suspend fun save(config: AppConfig) +} diff --git a/app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt b/app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt new file mode 100644 index 0000000..26d270f --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/data/DataStoreConfigRepository.kt @@ -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 = 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") + } +} diff --git a/app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt b/app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt new file mode 100644 index 0000000..7eb7892 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/data/MockSub2ApiRepository.kt @@ -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 { + return if (config.isConfigured) Result.success(Unit) else Result.failure( + IllegalStateException("Missing Sub2API URL or admin key"), + ) + } +} diff --git a/app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt b/app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt new file mode 100644 index 0000000..e69d578 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/data/Sub2ApiRepository.kt @@ -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 +} diff --git a/app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt b/app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt new file mode 100644 index 0000000..0cc6367 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/domain/MetricFormatters.kt @@ -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() + } +} diff --git a/app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt b/app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt new file mode 100644 index 0000000..7988109 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/domain/Sub2ApiModels.kt @@ -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, + val modelTop: List, + 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, +} diff --git a/app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt b/app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt new file mode 100644 index 0000000..d90135d --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/sync/RefreshScheduler.kt @@ -0,0 +1,7 @@ +package com.sub2api.monitor.sync + +interface RefreshScheduler { + fun schedule(intervalMinutes: Int) +} + +fun sanitizeRefreshIntervalMinutes(intervalMinutes: Int): Int = intervalMinutes.coerceAtLeast(15) diff --git a/app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt b/app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt new file mode 100644 index 0000000..a784e99 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/sync/WidgetRefreshWorker.kt @@ -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() + }, + ) + } +} diff --git a/app/src/main/java/com/sub2api/monitor/sync/WorkManagerRefreshScheduler.kt b/app/src/main/java/com/sub2api/monitor/sync/WorkManagerRefreshScheduler.kt new file mode 100644 index 0000000..50523ca --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/sync/WorkManagerRefreshScheduler.kt @@ -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( + sanitized.toLong(), + TimeUnit.MINUTES, + ).build() + + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } + + private companion object { + const val WORK_NAME = "sub2api_widget_refresh" + } +} diff --git a/app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt b/app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt new file mode 100644 index 0000000..88368f0 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/ui/ConfigScreen.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt b/app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt new file mode 100644 index 0000000..40c0359 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/ui/ConfigViewModel.kt @@ -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 = "配置已保存") + } + } +} diff --git a/app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt b/app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt new file mode 100644 index 0000000..5ec08e6 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/widget/RefreshWidgetAction.kt @@ -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) + } +} diff --git a/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt new file mode 100644 index 0000000..f4e32f5 --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidget.kt @@ -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()) + } + 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()) + } + + 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) +} diff --git a/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt new file mode 100644 index 0000000..4ba6aeb --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiMonitorWidgetReceiver.kt @@ -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() +} diff --git a/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt new file mode 100644 index 0000000..b9a782f --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/widget/Sub2ApiWidgetViewModel.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt b/app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt new file mode 100644 index 0000000..4518c0a --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/widget/WidgetState.kt @@ -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" +} diff --git a/app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt b/app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt new file mode 100644 index 0000000..b4dddfa --- /dev/null +++ b/app/src/main/java/com/sub2api/monitor/widget/WidgetStateRepository.kt @@ -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 = context.widgetStateDataStore.data.map { preferences -> + preferences[SNAPSHOT]?.let { value -> + runCatching { json.decodeFromString(value) }.getOrNull() + } + } + + val lastError: Flow = 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") + } +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..73264be --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/widget_loading.xml b/app/src/main/res/layout/widget_loading.xml new file mode 100644 index 0000000..aa3de57 --- /dev/null +++ b/app/src/main/res/layout/widget_loading.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..2707d40 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Sub2API Monitor + Sub2API monitoring dashboard widget + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..43bf033 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,4 @@ + + +