- AGENTS.md: add Ollama to project overview, service list, data flow, widget service count (3→4), credential echo coverage, dashboard item count (7→8), fix widget refresh flow (DEEPSEEK+OPENCODE_GO → selectedService+DEEPSEEK) - README.md: add Ollama to title, features, download requirements, architecture diagram, project structure; mark Jsoup as unused}
195 lines
13 KiB
Markdown
195 lines
13 KiB
Markdown
# 雨晴Token — AI 余额查询 APP
|
||
|
||
## 项目概述
|
||
|
||
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。
|
||
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
|
||
CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 auth.json 刷新 token 后查询 wham 用量。Ollama Pro 通过 Cookie 抓取 settings 页 HTML 解析用量百分比。APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
||
|
||
## 技术栈
|
||
|
||
- Kotlin 100% · Jetpack Compose + Material 3
|
||
- MVVM + Repository + `RefreshBalanceUseCase`(单一 UseCase,按 ServiceType 分发)
|
||
- Hilt + KSP(DI)
|
||
- Retrofit 2 + OkHttp 4 + Kotlinx Serialization
|
||
- DataStore(本地缓存)+ Android Keystore(凭据加密,AES-256 GCM)
|
||
- `minSdk=31`(Android 12+)
|
||
- `material3-window-size-class` — 平板自适应布局
|
||
- WorkManager(计划中)
|
||
|
||
## 当前实现状态
|
||
|
||
**服务**:
|
||
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
||
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
||
- ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`)
|
||
- ✅ Codex / ChatGPT Plus — 粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新
|
||
- ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532)
|
||
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
|
||
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
||
|
||
**用量统计系统**:
|
||
- ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
|
||
- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
|
||
- ✅ `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)— 持久化 UTC 偏好,下次进入自动恢复
|
||
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
||
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
|
||
|
||
**凭据回显红线**:
|
||
|
||
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。
|
||
> 当前覆盖:`ApiKeyCredential`(API Key 输入框)、`SessionCredential`(Cookie 输入框 / `ollamaCookie` 字段)、`CodexCredential`(auth.json 输入框)。
|
||
> 新增凭据类型(如 OpenCode Zen / MiMo)时必须同步添加对应的 `load()` 回显分支,否则用户保存后看不到已存内容。
|
||
|
||
**ViewModel 加载机制红线**:
|
||
|
||
> ⚠️ 三个 ViewModel 的 `init` 块**已移除**,不再自动加载。数据加载由 Composable 层的 `LaunchedEffect(Unit)` 显式触发:
|
||
> - OCGO 页面:`LaunchedEffect(Unit) { viewModel.load() / loadStats() / loadData() }`
|
||
> - CCGO 页面:`LaunchedEffect(Unit) { viewModel.setWorkspace(wid) }`(`setWorkspace` 内部调 `load()`)
|
||
> - CCGO 页面通过 `autoLoad = false` 参数跳过 Screen 内的 `LaunchedEffect` 重复 load
|
||
>
|
||
> 原因:`init` 自动加载时 `workspaceIdOverride` 为 null,协程读到 OCGO 凭据,导致 CCGO 页面闪现 OCGO 数据。
|
||
|
||
**hiltViewModel key 红线**:
|
||
|
||
> ⚠️ `hiltViewModel(key = key)` 的 key 在 ViewModelStore 内全局唯一、不区分类型。
|
||
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
|
||
> 当前方案:`chartVm` 用 `"ccgo_chart_$wid"`,`usageVm` 用 `"ccgo_$wid"`(与 Dashboard 首页 `CommandCodeUsageStatsCard` 共享实例)。
|
||
|
||
**首页布局**:
|
||
|
||
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
|
||
> 页面仅 8 个 item,`LazyColumn` 的 dispose/recompose 会导致用量卡片的 `LaunchedEffect` 反复触发,产生卡顿。
|
||
>
|
||
> **自适应断点**:容器宽度 > 600dp 时卡片双列(`BoxWithConstraints`),≤600dp 时单列。
|
||
> OCGO / CCGO 服务余额卡底部均提供「查看用量详情」入口,未配置凭据时不显示。
|
||
>
|
||
> **长按拖拽排序**:基于 Compose 原生 `detectDragGesturesAfterLongPress`,不引入第三方库。
|
||
> 核心设计:拖动中不修改真实布局顺序(手势节点不动),其它卡片用 `displacementFor()` 计算的 `offset` 做视觉让位。
|
||
> 换位判断采用"拖拽卡片中心点命中目标格"模型——累计手指偏移计算拖拽中心在窗口的坐标,
|
||
> 与冻结的格子中心表 `itemCenterById` 比对,进入目标格激活区域(卡片短边的 45%)后才切换 `dragTargetIndex`。
|
||
> 自动滚动时对浮动卡片和格子中心表同步做 `y - consumed` 补偿,防止飞走。
|
||
> 松手后通过 `settleDraggedItem()` 落位并持久化到 SharedPreferences(`dashboard_card_order`)。
|
||
>
|
||
> 关键状态拆分:
|
||
> - `visualDragOffsetX/Y` —— 视觉跟手偏移,持续累计
|
||
> - `dragStartCenterX/YInWindow` —— 拖动开始时卡片中心在窗口位置,作为偏移基准
|
||
> - `dragFromIndex` / `dragTargetIndex` —— 真实 index 和目标 index,不触发重组
|
||
> - `itemCenterById` —— 拖动中冻结的格子中心坐标表;使用普通 `HashMap`,不要改回 Compose StateMap(滚动/返回动画期间 `onGloballyPositioned` 高频写入会触发重组卡顿)
|
||
> - `displayOrder` —— 真实布局顺序,拖动中不改
|
||
> - `cardOrder`(外层 SharedPreferences)—— 持久化的用户偏好顺序
|
||
|
||
**图表自适应**:
|
||
|
||
> `UsageDetailScreen` / `UsageChartScreen` 通过 `BoxWithConstraints` 判断容器宽度 > 700dp 时图表并排(前两张 50/50,第三张独占一行),反之纵向堆叠。阈值 700dp 高于 Dashboard 的 600dp,因为图表卡片需要更多空间。
|
||
|
||
**页面导航**:
|
||
```
|
||
Compact(手机):
|
||
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
|
||
↘ UsageData(原始数据)
|
||
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
|
||
↘ CCGO_USAGE_DATA(原始数据)
|
||
|
||
返回用 guardedPop()(200ms 时间戳围栏,PopGuard 非 State 对象)+ Android predictive back。
|
||
Manifest 开启 `android:enableOnBackInvokedCallback="true"`;`navigation-compose` 保持 2.9.x 以上,使用后续 predictive back 修复。
|
||
Compact 根 `NavHost` 必须显式配置 `enterTransition` / `exitTransition` / `popEnterTransition` / `popExitTransition` 四项:
|
||
前进统一左滑,返回统一右滑,避免只配置 pop 时短时间返回混入默认淡入淡出。
|
||
当前页面背景/渐变层不适合 `scaleOut` 或长透明淡出类返回动画,容易出现透明背景和文字叠影;若要改动画,先处理 destination 的不透明背景层。
|
||
PopGuard 额外检查 previousBackStackEntry != null,且 popBackStack() 返回 false 时 reset 围栏。
|
||
|
||
Expanded(平板,≥840dp):
|
||
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐
|
||
│ │ │ ServiceDetail / OCGOUsage / CCGOUsage │
|
||
│ │ │ Settings(内嵌 NavHost) │
|
||
└────────────────────────────────┘ └────────────────────────────────────────┘
|
||
右侧用量详情内部子路由:图表 → 总览 / 原始数据(OCGO/CCGO/Settings 各自用局部 NavHost)
|
||
面板切换用 when(pane) 分支(同一帧原子重组,零穿透),子路由由局部 NavHost 的 popBackStack() 内置防护。
|
||
```
|
||
|
||
**桌面小组件(Widget)**:
|
||
- 显示当前选中服务的用量+DeepSeek 余额
|
||
- 支持四服务切换:OCGO / CCGO / Codex / Ollama(右上角 ↻ 按钮旁的切换按钮循环切换)
|
||
- 右上角 ↻ 手动刷新按钮(后台广播 → `WidgetRefreshReceiver` → EntryPoints 获取 `RefreshBalanceUseCase`)
|
||
- 刷新逻辑:只刷新当前选中服务 + DeepSeek,不再串行刷全部服务;25s 超时保护;`isRefreshing` 互斥锁防连续点击
|
||
- 点击刷新后立即更新 Widget 右上角时间为"刷新中..."(`showRefreshing()`),再后台请求网络
|
||
- 进度条颜色按百分比动态变化(<50% 草莓粉 / 50-80% 暖橙 / >80% 玫红)
|
||
- **MIUI Widget 适配**:`miuiWidget` 标识 → 可拖入负一屏;`miui.appwidget.action.APPWIDGET_UPDATE` 曝光刷新(划到即触发,20s 冷却);`@android:id/background` 根布局 ID(系统统一裁切圆角)
|
||
- **自动刷新**:`onUpdate()` 内缓存为空或超过 5 分钟冷却时自动发送 `WidgetRefreshReceiver` 广播
|
||
- **一键添桌面**:Dashboard 顶部栏 + 按钮 → `requestPinAppWidget`(有 fallback 到 `ACTION_APPWIDGET_PICK`)
|
||
- 服务切换状态持久化到 SharedPreferences(`widget_auto_refresh` 中的 `display_service` key),切换后立即调用 `notifyDataChanged` 触发 `onUpdate()` 渲染
|
||
|
||
## RemoteViews 兼容性红线
|
||
|
||
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:
|
||
|
||
| ❌ 不可用 | ✅ 替代方案 |
|
||
|-----------|------------|
|
||
| `<Space>` | 透明 ProgressBar(`0dp + weight=1`) |
|
||
| `<View>` | ProgressBar 或 TextView |
|
||
| `<ImageView>` + 矢量 drawable | PNG(`drawable-nodpi`) |
|
||
| `<TextView>` `0dp+weight=1` 空串 spacer | ProgressBar spacer |
|
||
| `<TextView>` 固定 dp 宽度 + `gravity` | 仅固定 dp,不加 gravity |
|
||
|
||
## 关键命令
|
||
|
||
```bash
|
||
cd /data/user/0/com.ai.assistance.operit/files/workspace/Rainytoken
|
||
export ANDROID_HOME=$HOME/Android
|
||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
|
||
./gradlew assembleDebug
|
||
# APK: app/build/outputs/apk/debug/app-debug.apk
|
||
```
|
||
|
||
## 数据流
|
||
|
||
```
|
||
DashboardViewModel.refresh()
|
||
→ RefreshBalanceUseCase(service)
|
||
→ DeepSeekRepository.fetchBalance() / OpenCodeGoRepository.fetchBalance()
|
||
→ CommandCodeGoRepository.fetchBalance() / CodexRepository.fetchBalance()
|
||
→ OllamaRepository.fetchBalance()
|
||
→ BalanceCache.put(service, result)
|
||
→ OpenCodeGoWidgetProvider.notifyDataChanged(context)
|
||
|
||
Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync()
|
||
→ OCGO: SyncUsageUseCase.fullSync() / incrementalSync()
|
||
→ OpenCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||
→ UsageCache.insertAll() → persist() → invalidateCache()
|
||
→ CCGO: SyncCommandCodeUsageUseCase.fullSync() / incrementalSync()
|
||
→ CommandCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||
→ UsageViewModel.loadStats() → getRecords() → 内存聚合
|
||
|
||
CCGO 清除按钮(详情页顶栏):
|
||
点击 → AlertDialog 警告弹窗 → 3s 倒计时确认
|
||
→ UsageViewModel.clearAndResync()
|
||
→ UsageCache.deleteByWorkspaceId("commandcode")
|
||
→ SyncCommandCodeUsageUseCase.fullSync()
|
||
→ loadStats() → onBack()
|
||
|
||
Widget 刷新按钮:
|
||
↻ → PendingIntent.getBroadcast() → WidgetRefreshReceiver
|
||
→ EntryPoints → RefreshBalanceUseCase(selectedService + DEEPSEEK)
|
||
→ notifyDataChanged()
|
||
|
||
MIUI 曝光刷新(用户划到负一屏/桌面):
|
||
→ miui.appwidget.action.APPWIDGET_UPDATE → onReceive() → onUpdate()
|
||
→ 读缓存渲染
|
||
→ 缓存为空/过期? → sendBroadcast(WidgetRefreshReceiver)
|
||
```
|
||
|
||
## 品牌色
|
||
|
||
| 用途 | 色值 |
|
||
|------|------|
|
||
| 主品牌草莓粉 | `#FF85A2` |
|
||
| 樱粉背景/点缀 | `#FFD1DC` |
|
||
| 浅粉背景 | `#FFF0F5` |
|
||
| 深暖文字 | `#3D2C35` |
|
||
| 暖灰辅助 | `#8A7A82` |
|
||
| 玫红(>80% 警示) | `#E91E63` |
|
||
| 暖橙(50-80%) | `#FFA726` |
|
||
|
||
深色模式下文字颜色由 `inkWarm()` / `inkMuted()` composable 自动切换(定义在 `Theme.kt`),静态资源通过 `drawable-night/` / `layout-night/` 适配。 |