diff --git a/AGENTS.md b/AGENTS.md index a9deb84..077bc51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut **用量统计系统**: - ✅ `UsageCache`(Room,indexed on workspaceId+timeCreated)— DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore JSON 迁移 -- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合) +- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合,按 `getIdsByWorkspace(wid)` 过滤避免跨 workspace 碰撞) - ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿 - ✅ `UsageChartViewModel` — 图表粒度(5h/**12h(10min)**/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表;支持 **UTC+0/UTC+8 时区切换**(桶边界+标签双感知);自定义日/月/范围保存 `LocalDate` 语义,切换 UTC 偏好时重新计算边界;**自动降级**(5h无数据→12h→7天→当月) - ✅ `ChartSettingsStore`(DataStore Preferences + StateFlow)— 持久化 UTC 偏好,`useUtc8Flow` 异步读取(已移除 runBlocking) @@ -124,8 +124,8 @@ Expanded(平板,≥840dp): **小组件点击绑定**: -> `widget_wordmark` + `widget_open_hint`(`›` 箭头)→ 打开 APP;`widget_content` / `widget_switch` / `widget_service_title` → 切换服务广播 `ACTION_SWITCH_SERVICE`;`widget_refresh` → 刷新广播。 -> 切换 requestCode=2,刷新 requestCode=1。左上角 `›` 箭头是可点击进 APP 的视觉提示。 +> `widget_wordmark` + `widget_open_hint`(`>` 箭头)→ 打开 APP;`widget_content` / `widget_switch` / `widget_service_title` → 切换服务广播 `ACTION_SWITCH_SERVICE`;`widget_refresh` → 刷新广播。 +> 切换 requestCode=2,刷新 requestCode=1。左上角 `>` 箭头是可点击进 APP 的视觉提示(ASCII `>` 替代 Unicode `›`,确保全 ROM 兼容)。 **使用小技巧系统**: @@ -146,7 +146,7 @@ Expanded(平板,≥840dp): |-----------|------------| | `` | 透明 ProgressBar(`0dp + weight=1`) | | `` | ProgressBar 或 TextView | -| `` + 矢量 drawable | PNG(`drawable-nodpi`) | +| `` + 矢量 drawable | PNG(`drawable-nodpi`)。Widget logo 必须用 `_widget` 后缀 PNG(`ic_opencode_go_logo_widget.png`、`ic_codex_logo_widget.png`);DeepSeek / Ollama 已有 PNG(`ic_deepseek_logo.png`、`ic_ollama_logo.png`)。Compose 层(`ServiceIcon.kt`)继续用 VectorDrawable XML。 | | `` `0dp+weight=1` 空串 spacer | ProgressBar spacer | | `` 固定 dp 宽度 + `gravity` | 仅固定 dp,不加 gravity | @@ -168,7 +168,7 @@ DashboardViewModel.refresh() → DeepSeekRepository.fetchBalance() / OpenCodeGoRepository.fetchBalance() → CommandCodeGoRepository.fetchBalance() / CodexRepository.fetchBalance() → OllamaRepository.fetchBalance() - → BalanceCache.put(service, result) + → BalanceCache.put(service, result) — put() 在 dataStore.edit {} 互斥锁内做 read-modify-write,避免并发覆盖 → OpenCodeGoWidgetProvider.notifyDataChanged(context) Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync() diff --git a/README.md b/README.md index 2455408..00befff 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go | 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 | | 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 | | 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 | -| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 › 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) | +| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 > 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) | | 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 | | 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 | | ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 | diff --git a/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt b/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt index 3f5272c..8d606ae 100644 --- a/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt +++ b/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt @@ -26,9 +26,8 @@ class BalanceCache( private val json: Json = DefaultJson ) { - // v2: 2026-06 重构 extras 字段名(rollingUsagePercent → rolling.pct 等)。 - // 升 v2 让旧版缓存失效,避免显示 0%。 - private val cacheKey = stringPreferencesKey("balance_cache_v2") + // v3: 2026-07 统一 Codex amount 为 usedPct(之前是 remainingPct),旧缓存失效 + private val cacheKey = stringPreferencesKey("balance_cache_v3") suspend fun getAll(): Map { val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap() @@ -44,23 +43,30 @@ class BalanceCache( suspend fun get(service: ServiceType): CachedBalance? = getAll()[service] suspend fun put(service: ServiceType, balance: ServiceBalance) { - val current = getAll().toMutableMap() - current[service] = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis()) - persist(current) + // 在 dataStore.edit 的互斥锁内做 read-modify-write,避免并发覆盖 + dataStore.edit { prefs -> + val raw = prefs[cacheKey] + val current = if (raw != null) { + runCatching { + json.decodeFromString( + MapSerializer(ServiceType.serializer(), CachedBalance.serializer()), + raw + ) + }.getOrDefault(emptyMap()) + } else emptyMap() + val updated = current.toMutableMap() + updated[service] = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis()) + prefs[cacheKey] = json.encodeToString( + MapSerializer(ServiceType.serializer(), CachedBalance.serializer()), + updated + ) + } } suspend fun clear() { dataStore.edit { it.remove(cacheKey) } } - private suspend fun persist(map: Map) { - val raw = json.encodeToString( - MapSerializer(ServiceType.serializer(), CachedBalance.serializer()), - map - ) - dataStore.edit { it[cacheKey] = raw } - } - companion object { val DefaultJson = Json { ignoreUnknownKeys = true diff --git a/app/src/main/java/com/rainy/token/data/local/UsageCache.kt b/app/src/main/java/com/rainy/token/data/local/UsageCache.kt index 006784f..924ea32 100644 --- a/app/src/main/java/com/rainy/token/data/local/UsageCache.kt +++ b/app/src/main/java/com/rainy/token/data/local/UsageCache.kt @@ -62,6 +62,12 @@ class UsageCache( return dao.getAllIds().toSet() } + /** 按 workspaceId 获取已有记录 ID 集合(增量同步用) */ + suspend fun getIdsByWorkspace(workspaceId: String): Set { + ensureMigrated() + return dao.getIdsByWorkspace(workspaceId).toSet() + } + /** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */ suspend fun deleteByWorkspaceId(workspaceId: String) { ensureMigrated() diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index 4e837cc..95c7299 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -80,7 +80,7 @@ class CodexRepository( c["balance"]?.jsonPrimitive?.floatOrNull?.let { put("usageCredits", it.toString()) } } } - val balance = ServiceBalance(ServiceType.CODEX, primary.remainingPct.toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras) + val balance = ServiceBalance(ServiceType.CODEX, (100 - primary.remainingPct).coerceIn(0, 100).toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras) balanceCache.put(ServiceType.CODEX, balance) credentialRepository.save(effectiveCred.copy(lastVerifiedAt = System.currentTimeMillis())) Result.success(balance) diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt index cfd6f12..61c6674 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -175,22 +175,23 @@ class CommandCodeUsageRepository( ) } - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'X'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - private val dateFormatNoMillis = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'X'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - private fun parseIsoDate(iso: String): Long? { // 处理末尾 Z 和时区偏移 val normalized = iso .replace("Z", "X") .replace(Regex("""[+-]\d{2}:\d{2}$"""), "X") return try { - dateFormat.parse(normalized)?.time - ?: dateFormatNoMillis.parse(normalized)?.time + // SimpleDateFormat 非线程安全,每次创建新实例 + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'X'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + sdf.parse(normalized)?.time + ?: run { + val sdf2 = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'X'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + sdf2.parse(normalized)?.time + } } catch (_: Exception) { null } } diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt index d260eab..26c8f30 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt @@ -211,15 +211,16 @@ class OpenCodeUsageRepository( else num } - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - private fun extractDate(obj: String, key: String): Long? { // 匹配模式: key:new Date("2026-06-15T20:46:31.000Z") val pattern = Regex("""["']?${Regex.escape(key)}["']?\s*:\s*(?:\$\w+\[?\d*\]?=\s*)?new Date\("([^"]+)"\)""") return pattern.find(obj)?.groupValues?.get(1)?.let { - runCatching { dateFormat.parse(it)?.time }.getOrNull() + // SimpleDateFormat 非线程安全,每次创建新实例 + runCatching { + java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(it)?.time + }.getOrNull() } } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt index 2966c24..061b90c 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt @@ -64,7 +64,9 @@ class SyncCommandCodeUsageUseCase @Inject constructor( val (records, nextCursor) = pageResult.getOrThrow() if (records.isEmpty()) break - val existingIds = cache.getAllIds() + // 按 workspace 过滤本地已有 ID,避免跨 workspace 碰撞 + val workspaceId = records.firstOrNull()?.workspaceId ?: CommandCodeUsageRepository.CCGO_WORKSPACE_ID + val existingIds = cache.getIdsByWorkspace(workspaceId) val newRecords = records.filter { it.id !in existingIds } if (newRecords.isEmpty()) break diff --git a/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt index 3498db5..f2c10c2 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt @@ -61,7 +61,9 @@ class SyncUsageUseCase @Inject constructor( val records = pageResult.getOrThrow() if (records.isEmpty()) break - val existingIds = cache.getAllIds() // 本地已有 ID 集合 + // 用第一条记录的 workspaceId 过滤本地已有 ID,避免跨 workspace 碰撞 + val workspaceId = records.firstOrNull()?.workspaceId ?: "" + val existingIds = if (workspaceId.isNotEmpty()) cache.getIdsByWorkspace(workspaceId) else cache.getAllIds() val newRecords = records.filter { it.id !in existingIds } if (newRecords.isEmpty()) break // 整页都已存在 → 接到旧数据 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 0376276..a15dcc1 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 @@ -242,7 +242,7 @@ internal fun CodexMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "5h 剩余", + text = "5h 已用", style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 3b051cf..3b3537f 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -696,7 +696,7 @@ private fun mainCardLabel(service: ServiceType): String = when (service) { ServiceType.DEEPSEEK -> "当前余额" ServiceType.OPENCODE_GO -> "5h 实时用量" ServiceType.COMMANDCODE_GO -> "月度余额" - ServiceType.CODEX -> "5h 剩余额度" + ServiceType.CODEX -> "5h 已用量" ServiceType.OLLAMA -> "Session 用量" } 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 3b18184..3974242 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 @@ -313,8 +313,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { } private fun widgetLogo(service: ServiceType): Int = when (service) { - ServiceType.OPENCODE_GO, ServiceType.COMMANDCODE_GO -> R.drawable.ic_opencode_go_logo - ServiceType.CODEX -> R.drawable.ic_codex_logo + ServiceType.OPENCODE_GO, ServiceType.COMMANDCODE_GO -> R.drawable.ic_opencode_go_logo_widget // PNG for RemoteViews compatibility + ServiceType.CODEX -> R.drawable.ic_codex_logo_widget // PNG for RemoteViews compatibility ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo ServiceType.OLLAMA -> R.drawable.ic_ollama_logo } diff --git a/app/src/main/res/drawable-nodpi/ic_codex_logo_widget.png b/app/src/main/res/drawable-nodpi/ic_codex_logo_widget.png new file mode 100644 index 0000000..f4732ab Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_codex_logo_widget.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_opencode_go_logo_widget.png b/app/src/main/res/drawable-nodpi/ic_opencode_go_logo_widget.png new file mode 100644 index 0000000..6c5484e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_opencode_go_logo_widget.png differ diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml index 3ee145b..ded7774 100644 --- a/app/src/main/res/layout-night/widget_opencode_go.xml +++ b/app/src/main/res/layout-night/widget_opencode_go.xml @@ -34,7 +34,7 @@ android:id="@+id/widget_open_hint" android:layout_width="10dp" android:layout_height="24dp" - android:text="›" + android:text=">" android:textColor="#8A7A82" android:textSize="14sp" android:gravity="center" /> diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml index e0ccb74..b2e8810 100644 --- a/app/src/main/res/layout/widget_opencode_go.xml +++ b/app/src/main/res/layout/widget_opencode_go.xml @@ -35,7 +35,7 @@ android:id="@+id/widget_open_hint" android:layout_width="10dp" android:layout_height="24dp" - android:text="›" + android:text=">" android:textColor="#C4A0AB" android:textSize="14sp" android:gravity="center" />