fix: 对抗审查修复5项问题

1. BalanceCache.put() 并发写丢失 — read-modify-write 搬进 dataStore.edit{} 互斥锁
2. Widget VectorDrawable → PNG — 生成 ic_opencode_go_logo_widget.png 和 ic_codex_logo_widget.png
3. SimpleDateFormat 线程安全 — 共享实例改为每次调用创建局部实例
4. 增量同步按 workspace 过滤 — 新增 getIdsByWorkspace() 避免跨 workspace ID 碰撞
5. Codex amount 语义统一为 usedPct — balance_cache 版本 v2→v3 旧缓存失效

Doc: AGENTS.md/README.md 同步更新(›→>、RemoteViews PNG文件名、增量同步描述)
This commit is contained in:
WaterRain 2026-07-07 05:40:16 +00:00
parent c5505c896a
commit 75a5c8f81e
No known key found for this signature in database
16 changed files with 62 additions and 44 deletions

View File

@ -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):
|-----------|------------|
| `<Space>` | 透明 ProgressBar(`0dp + weight=1`) |
| `<View>` | ProgressBar 或 TextView |
| `<ImageView>` + 矢量 drawable | PNG(`drawable-nodpi`) |
| `<ImageView>` + 矢量 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。 |
| `<TextView>` `0dp+weight=1` 空串 spacer | ProgressBar spacer |
| `<TextView>` 固定 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()

View File

@ -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 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |

View File

@ -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<ServiceType, CachedBalance> {
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<ServiceType, CachedBalance>) {
val raw = json.encodeToString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
map
)
dataStore.edit { it[cacheKey] = raw }
}
companion object {
val DefaultJson = Json {
ignoreUnknownKeys = true

View File

@ -62,6 +62,12 @@ class UsageCache(
return dao.getAllIds().toSet()
}
/** 按 workspaceId 获取已有记录 ID 集合(增量同步用) */
suspend fun getIdsByWorkspace(workspaceId: String): Set<String> {
ensureMigrated()
return dao.getIdsByWorkspace(workspaceId).toSet()
}
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
suspend fun deleteByWorkspaceId(workspaceId: String) {
ensureMigrated()

View File

@ -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)

View File

@ -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 }
}

View File

@ -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()
}
}
}

View File

@ -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

View File

@ -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 // 整页都已存在 → 接到旧数据

View File

@ -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)

View File

@ -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 用量"
}

View File

@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

View File

@ -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" />

View File

@ -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" />