diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index fb56f8d..9b187fa 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -104,18 +104,25 @@ class OpenCodeGoRepository( val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO) // 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染) + // usage/limit 为页面新增的用量与限额字段(单位以服务端定义为准,保留供详情页展示,UI 兼容缺失场景) val extras = buildMap { windows["rollingUsage"]?.let { w -> put("rolling.pct", w.usagePercent.toString()) put("rolling.resetInSec", w.resetInSec.toString()) + w.usage?.let { put("rolling.usage", it.toString()) } + w.limit?.let { put("rolling.limit", it.toString()) } } windows["weeklyUsage"]?.let { w -> put("weekly.pct", w.usagePercent.toString()) put("weekly.resetInSec", w.resetInSec.toString()) + w.usage?.let { put("weekly.usage", it.toString()) } + w.limit?.let { put("weekly.limit", it.toString()) } } windows["monthlyUsage"]?.let { w -> put("monthly.pct", w.usagePercent.toString()) put("monthly.resetInSec", w.resetInSec.toString()) + w.usage?.let { put("monthly.usage", it.toString()) } + w.limit?.let { put("monthly.limit", it.toString()) } } } @@ -257,10 +264,14 @@ class OpenCodeGoRepository( val braceEnd = findMatchingBrace(html, braceStart) ?: continue val body = html.substring(braceStart, braceEnd + 1) - val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull() + val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull() val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull() + // usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别) + // exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀 + val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull() + val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull() if (pct != null && reset != null) { - result[field] = ScrapedWindow(pct, reset) + result[field] = ScrapedWindow(pct, reset, usage, limit) } } @@ -288,9 +299,23 @@ class OpenCodeGoRepository( /** * 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。 * 跳过 status 字符串值("ok" 之类)。 + * + * @param exactKey 为 true 时要求 key 后紧跟 ':'(精确字段匹配), + * 避免 "usage" 误命中 "usagePercent" 这类前缀字段。 */ - private fun extractNumberAfterKey(body: String, key: String): String? { - val keyIdx = body.indexOf(key) + private fun extractNumberAfterKey(body: String, key: String, exactKey: Boolean = false): String? { + val keyIdx = if (exactKey) { + // 精确匹配:key 后必须紧跟 ':'(如 "usage:"),不能是 "usagePercent" + var idx = body.indexOf(key) + while (idx >= 0) { + val after = idx + key.length + if (after < body.length && body[after] == ':') break + idx = body.indexOf(key, idx + 1) + } + idx + } else { + body.indexOf(key) + } if (keyIdx < 0) return null var i = keyIdx + key.length // 跳过 ":" 后面所有非数字、非负号、非小数点字符 @@ -313,7 +338,12 @@ class OpenCodeGoRepository( return body.substring(start, i).ifEmpty { null } } - internal data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long) + internal data class ScrapedWindow( + val usagePercent: Float, + val resetInSec: Long, + val usage: Long? = null, + val limit: Long? = null + ) } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 4fec0d9..9b6b948 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -38,6 +38,7 @@ import com.rainy.token.ui.theme.StatusOrange import com.rainy.token.ui.theme.inkMuted import java.text.SimpleDateFormat import java.util.Date +import kotlin.math.roundToInt import java.util.Locale // ── Service-specific balance Composables ── @@ -153,9 +154,9 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { @Composable internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) { val windows = listOf( - Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), - Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), - Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) + Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { windows.forEach { (label, pct, resetSec) -> diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 3b3101b..fed71a9 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.runBlocking import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlin.math.roundToInt /** * OpenCode Go 桌面小组件(MIUI Widget)。 @@ -173,13 +174,13 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { ServiceType.OPENCODE_GO -> { setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly)) populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, - pct = extras["rolling.pct"]?.toIntOrNull(), + pct = extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(), resetSec = extras["rolling.resetInSec"]?.toLongOrNull()) populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset, - pct = extras["weekly.pct"]?.toIntOrNull(), + pct = extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(), resetSec = extras["weekly.resetInSec"]?.toLongOrNull()) populateRow(views, R.id.row3_pct, R.id.row3_bar, R.id.row3_reset, - pct = extras["monthly.pct"]?.toIntOrNull(), + pct = extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(), resetSec = extras["monthly.resetInSec"]?.toLongOrNull()) } ServiceType.COMMANDCODE_GO -> { diff --git a/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt index 077dc80..bcd7536 100644 --- a/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt @@ -19,15 +19,21 @@ class OpenCodeGoRepositoryTest { * * The real format looks like: * rollingUsage:$R[0]={status:"ok",usagePercent:42,resetInSec:12345} + * + * @param usage 可选:页面新增的用量字段(usage) + * @param limit 可选:页面新增的限额字段(limit) */ private fun buildHtml( rolling: Pair? = Pair(42, 3600), weekly: Pair? = Pair(15, 86400), - monthly: Pair? = Pair(80, 2592000) + monthly: Pair? = Pair(80, 2592000), + usage: Long? = null, + limit: Long? = null ): String { fun windowJs(name: String, data: Pair?): String { if (data == null) return "" - return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}}""" + val extra = if (usage != null && limit != null) ",usage:$usage,limit:$limit" else "" + return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}$extra}""" } return """ @@ -54,15 +60,15 @@ class OpenCodeGoRepositoryTest { assertEquals(3, result.size) assertNotNull(result["rollingUsage"]) - assertEquals(42, result["rollingUsage"]!!.usagePercent) + assertEquals(42f, result["rollingUsage"]!!.usagePercent) assertEquals(3600L, result["rollingUsage"]!!.resetInSec) assertNotNull(result["weeklyUsage"]) - assertEquals(15, result["weeklyUsage"]!!.usagePercent) + assertEquals(15f, result["weeklyUsage"]!!.usagePercent) assertEquals(86400L, result["weeklyUsage"]!!.resetInSec) assertNotNull(result["monthlyUsage"]) - assertEquals(80, result["monthlyUsage"]!!.usagePercent) + assertEquals(80f, result["monthlyUsage"]!!.usagePercent) assertEquals(2592000L, result["monthlyUsage"]!!.resetInSec) } @@ -73,7 +79,7 @@ class OpenCodeGoRepositoryTest { assertEquals(1, result.size) assertNotNull(result["rollingUsage"]) - assertEquals(42, result["rollingUsage"]!!.usagePercent) + assertEquals(42f, result["rollingUsage"]!!.usagePercent) } @Test @@ -100,7 +106,7 @@ class OpenCodeGoRepositoryTest { assertEquals(1, result.size) assertNotNull(result["rollingUsage"]) - assertEquals(42, result["rollingUsage"]!!.usagePercent) + assertEquals(42f, result["rollingUsage"]!!.usagePercent) assertEquals(3600L, result["rollingUsage"]!!.resetInSec) } @@ -135,7 +141,7 @@ class OpenCodeGoRepositoryTest { ) val result = OpenCodeGoRepository.parseWindows(html) assertEquals(1, result.size) - assertEquals(0, result["rollingUsage"]!!.usagePercent) + assertEquals(0f, result["rollingUsage"]!!.usagePercent) assertEquals(0L, result["rollingUsage"]!!.resetInSec) } @@ -148,7 +154,7 @@ class OpenCodeGoRepositoryTest { ) val result = OpenCodeGoRepository.parseWindows(html) assertEquals(1, result.size) - assertEquals(99, result["rollingUsage"]!!.usagePercent) + assertEquals(99f, result["rollingUsage"]!!.usagePercent) assertEquals(9999999999L, result["rollingUsage"]!!.resetInSec) } @@ -163,4 +169,68 @@ class OpenCodeGoRepositoryTest { // Should not match because the prefix "rollingUsage:$R[" is missing assertTrue(result.isEmpty()) } + + @Test + fun `parses new page format with decimal percent and usage fields`() { + // 新页面(2026-08)实测格式:usagePercent 为小数,并新增 usage/limit 字段 + val html = """ + + """.trimIndent() + val result = OpenCodeGoRepository.parseWindows(html) + + assertEquals(3, result.size) + + val rolling = result["rollingUsage"]!! + assertEquals(1.1f, rolling.usagePercent) + assertEquals(1406L, rolling.resetInSec) + assertEquals(13314666L, rolling.usage) + assertEquals(1200000000L, rolling.limit) + + val weekly = result["weeklyUsage"]!! + assertEquals(2.5f, weekly.usagePercent) + assertEquals(339417L, weekly.resetInSec) + assertEquals(74497156L, weekly.usage) + assertEquals(3000000000L, weekly.limit) + + val monthly = result["monthlyUsage"]!! + assertEquals(7.2f, monthly.usagePercent) + assertEquals(1229614L, monthly.resetInSec) + assertEquals(434167747L, monthly.usage) + assertEquals(6000000000L, monthly.limit) + } + + @Test + fun `keeps decimal percent without rounding to int`() { + // 回归保护:usagePercent 为小数时不能被 toIntOrNull 丢掉 + val html = """ + + """.trimIndent() + val result = OpenCodeGoRepository.parseWindows(html) + + assertEquals(1, result.size) + assertEquals(0.5f, result["rollingUsage"]!!.usagePercent) + assertEquals(60L, result["rollingUsage"]!!.resetInSec) + } + + @Test + fun `tolerates missing usage and limit fields`() { + // 旧页面/旧缓存格式:无 usage/limit,不应影响窗口解析 + val html = buildHtml( + rolling = Pair(42, 3600), + weekly = null, + monthly = null + ) + val result = OpenCodeGoRepository.parseWindows(html) + + assertEquals(1, result.size) + assertEquals(42f, result["rollingUsage"]!!.usagePercent) + assertNull(result["rollingUsage"]!!.usage) + assertNull(result["rollingUsage"]!!.limit) + } } \ No newline at end of file