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:
parent
c5505c896a
commit
75a5c8f81e
10
AGENTS.md
10
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):
|
||||
|-----------|------------|
|
||||
| `<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()
|
||||
|
||||
@ -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 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 // 整页都已存在 → 接到旧数据
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 用量"
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
BIN
app/src/main/res/drawable-nodpi/ic_codex_logo_widget.png
Normal file
BIN
app/src/main/res/drawable-nodpi/ic_codex_logo_widget.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
app/src/main/res/drawable-nodpi/ic_opencode_go_logo_widget.png
Normal file
BIN
app/src/main/res/drawable-nodpi/ic_opencode_go_logo_widget.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 735 B |
@ -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" />
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user