diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..853dd4a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,198 @@ +# RainyToken 改进计划 + +> 临时计划文件,执行完毕后删除。 + +--- + +## 1. 迁移 UsageCache 到 Room + +**现状问题**: +- ~3700 条记录序列化为一个 JSON 字符串存入 DataStore 单 key +- 每次 `insertAll` → `loadAll()` 全量读取 → 追加 → `persist()` 全量序列化写回 +- APP 冷启动时内存缓存丢失,需反序列化整个 JSON 字符串 +- 数据只增不减,半年后可能上万条,写入和启动读取会线性恶化 +- `getRecords(workspaceId, fromTs, toTs)` 是全量遍历后过滤,Room 用索引查询是 O(log n) + +**目标**: +- 用 Room 替换 DataStore JSON 方案 +- `UsageRecord` 作为 Entity,`workspaceId` + `timeCreated` 建索引 +- `UsageCache` 接口不变(`getAll` / `insertAll` / `getRecords` / `getStatsByModel` / `getStatsByDay` / `getOverview` / `deleteByWorkspaceId` / `count` / `getDistinctModels` / `getLatest` / `getAllIds`),内部实现从 JSON 序列化改为 Room DAO 查询 +- 保留内存缓存层(`@Volatile cachedAll`),但改为按需查询而非全量加载 +- 数据迁移:首次启动时检查旧 DataStore 数据,一次性导入 Room,然后清理旧 key + +**涉及文件**: +- 新建:`data/local/UsageDatabase.kt`(Room Database + DAO) +- 新建:`data/local/UsageRecordEntity.kt`(Entity 定义,从现有 `UsageRecord` 迁移) +- 修改:`data/local/UsageCache.kt`(内部实现改为 Room DAO 调用,接口不变) +- 修改:`di/NetworkModule.kt`(@Provides RoomDatabase 实例) +- 修改:`app/build.gradle.kts`(添加 Room 依赖) + +**验证标准**: +- APP 启动后 OCGO / CCGO 用量数据正常显示 +- 下拉刷新触发增量同步后新数据正确入库 +- CCGO 清除按钮能正确删除并重新全量同步 +- 图表、总览、原始数据页面正常渲染 +- 冷启动不再有全量 JSON 反序列化卡顿 + +--- + +## 2. 加解析单元测试 + +**现状问题**: +- `OllamaRepository.parseUsage()` 和 `OpenCodeGoRepository.parseWindows()` 用正则解析 HTML +- 对方改一次页面结构就失效,且失效表现是静默的(显示 0% 而非报错) +- `CredentialEditViewModel.load()` 每次加新服务都要改回显分支,漏了用户看不到已存凭据 + +**目标**: +- 纯 JVM 单元测试,不需要 Android 设备 +- 测试用例用之前抓到的真实 HTML(Ollama settings 页)作为 fixture + +**涉及文件**: +- 新建:`app/src/test/java/com/rainy/token/data/repository/OllamaRepositoryTest.kt` + - 测试 plan 解析(Pro / Max / Free) + - 测试 session/weekly 百分比解析(含小数 35.1%) + - 测试 data-time 重置时间戳解析 + - 测试 data-model + data-requests 模型级数据解析 + - 测试无效 Cookie / 空页面 / HTML 结构变更场景 +- 新建:`app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt` + - 测试 rollingUsage / weeklyUsage / monthlyUsage 解析 + - 测试嵌套对象的括号匹配 + - 测试 HTML 无目标数据时的 fallback + +**验证标准**: +- `./gradlew test` 全部通过 +- 测试覆盖正则解析的主要路径和边界情况 + +--- + +## 3. DashboardScreen 拆分 + +**现状问题**: +- DashboardScreen.kt 1121 行,包含主框架 + 所有服务卡片 + 拖拽逻辑 + 工具函数 +- 加一个新服务要改这一个文件至少 3 处 + +**目标**: +- 拆成 3 个文件: + - `DashboardScreen.kt` — 主框架 + Scaffold + PullToRefresh(~200 行) + - `ServiceBalanceCards.kt` — 各服务的 MainBalance + UsageWindows Composable + - `DashboardDragState.kt` — 拖拽状态管理类 + 工具函数 + +**涉及文件**: +- 修改:`ui/dashboard/DashboardScreen.kt`(大幅精简) +- 新建:`ui/dashboard/ServiceBalanceCards.kt` +- 新建:`ui/dashboard/DashboardDragState.kt` + +**验证标准**: +- 编译通过 +- Dashboard 功能不变(卡片显示、拖拽排序、下拉刷新、自适应布局) +- 新增服务时只需改 `ServiceBalanceCards.kt` + `secondaryLine` + +--- + +## 4. 重复函数提取 + +**现状问题**: +- `formatAmount(Double)` — DashboardScreen.kt + ServiceDetailScreen.kt 各一份 +- `normalizeWindowLabel(String)` — DashboardScreen.kt + WidgetProvider 各一份 +- `formatResetInSec(Long)` / `formatReset(Long)` — DashboardScreen.kt + ServiceDetailScreen.kt + WidgetProvider 三份 +- `parseCookieString(String)` — CredentialEditViewModel.kt 独一份,但应该在公共位置 + +**目标**: +- 抽取到 `ui/components/FormatUtils.kt`(UI 层共享) +- `parseCookieString` 抽到 `data/repository/CookieUtils.kt` + +**涉及文件**: +- 新建:`ui/components/FormatUtils.kt` +- 新建:`data/repository/CookieUtils.kt` +- 修改:DashboardScreen.kt / ServiceDetailScreen.kt / OpenCodeGoWidgetProvider.kt / CredentialEditViewModel.kt(删除重复定义,改用 import) + +**验证标准**: +- 编译通过,无行为变化 + +--- + +## 5. Repository 加重试 + +**现状问题**: +- 所有 Repository 的 `fetchBalance()` 只做一次请求,失败就返回 `Result.failure` +- 网络抖动时用户需手动下拉刷新 + +**目标**: +- 对 `RepositoryError.Network` 和 `RepositoryError.ServerError(5xx)` 加一次指数退避重试(最多 2 次) +- 401/403/429 不重试 +- 可以做成 OkHttp Interceptor 或在 Repository 层包一层 `retryOnce` + +**涉及文件**: +- 新建或修改:`data/repository/RetryHelper.kt`(通用重试包装) +- 修改:各 Repository 的 `fetchBalance()`(包一层 retry) + +**验证标准**: +- 编译通过 +- 网络抖动时自动重试一次而非直接失败 +- 401/403 仍然立即失败不重试 + +--- + +## 6. CredentialEditViewModel 模板化 + +**现状问题**: +- `saveOpenCodeGoSession()` / `testAndSaveOpenCodeGo()` / `saveOllamaCredential()` / `testAndSaveOllama()` / `saveCommandCodeGoCredential()` / `testAndSaveCommandCodeGo()` 等方法成对出现,结构几乎一样 + +**目标**: +- 抽取通用的 `saveAndTestCredential(service, saveBlock, testBlock)` 模板方法 +- 各服务只传不同的 save + test lambda + +**涉及文件**: +- 修改:`ui/settings/CredentialEditViewModel.kt` + +**验证标准**: +- 编译通过 +- 各服务的保存/测试功能不变 +- 新增服务时只需写 save + test 两个 lambda,不再写完整的成对方法 + +--- + +## 7. 删除 Jsoup 依赖 + +**现状问题**: +- `build.gradle.kts` 引入了 `org.jsoup:jsoup`,代码里 0 处 import + +**涉及文件**: +- 修改:`app/build.gradle.kts`(删除 `implementation(libs.jsoup)`) + +**验证标准**: +- 编译通过 + +--- + +## 8. ChartSettingsStore 改 Flow + +**现状问题**: +- `ChartSettingsStore.getUseUtc8()` 用 `runBlocking` 同步读 DataStore,在 UI 线程调用 + +**目标**: +- 改成 `StateFlow` 暴露,Composable 层 `collectAsState` + +**涉及文件**: +- 修改:`data/local/ChartSettingsStore.kt` +- 修改:调用 `getUseUtc8()` 的 Composable 层 + +**验证标准**: +- 编译通过 +- 图表页 UTC 偏好切换正常 +- 退出再进入能恢复上次偏好 + +--- + +## 执行顺序 + +1. 迁移 Room(基础设施,后续都依赖它) +2. 加解析单元测试(在改其他代码前先有回归保护) +3. DashboardScreen 拆分 +4. 重复函数提取 +5. Repository 加重试 +6. CredentialEditViewModel 模板化 +7. 删除 Jsoup 依赖 +8. ChartSettingsStore 改 Flow + +每步完成后 `./gradlew assembleDebug` 验证编译,全部完成后执行 `git commit` 并删除此计划文件。 \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0a7e435..43eca05 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,11 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) + // Room + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + // Test testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) 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 566813b..006784f 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 @@ -3,91 +3,81 @@ package com.rainy.token.data.local import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json /** - * 用量记录本地缓存(DataStore,非 Room)。 + * 用量记录本地缓存(Room 实现)。 * - * 所有记录序列化为 JSON 列表存入 DataStore。 - * 约 3700 条记录,每条约 300 字节 → ~1.1MB,DataStore 可承受。 + * 接口与旧 DataStore 版本完全一致,内部改为 Room DAO 查询。 + * 首次访问时自动从旧 DataStore JSON 迁移数据到 Room。 */ class UsageCache( + private val context: Context, + private val dao: UsageDao, private val dataStore: DataStore, private val json: Json = Json { ignoreUnknownKeys = true; encodeDefaults = true } ) { - private val cacheKey = stringPreferencesKey("usage_cache_v1") + private val migrationMutex = Mutex() + @Volatile private var migrated = false - // ── 内存缓存:避免每次都反序列化整个 JSON ── - @Volatile private var cachedAll: List? = null - @Volatile private var cachedIds: Set? = null - - /** 读取全量(优先内存缓存) */ - private suspend fun loadAll(): List { - cachedAll?.let { return it } - val records = readFromStore() - cachedAll = records - cachedIds = records.map { it.id }.toSet() - return records - } - - private suspend fun readFromStore(): List { - val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyList() - return runCatching { - json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw) - }.getOrDefault(emptyList()) - } - - /** 写入后清空内存缓存 */ - private fun invalidateCache() { - cachedAll = null - cachedIds = null - } - - suspend fun getAll(): List = loadAll() - - fun getAllFlow(): Flow> = - dataStore.data.map { prefs -> - val raw = prefs[cacheKey] ?: return@map emptyList() - runCatching { - json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw) - }.getOrDefault(emptyList()) + /** 确保旧数据已迁移到 Room(仅执行一次) */ + private suspend fun ensureMigrated() { + if (migrated) return + migrationMutex.withLock { + if (migrated) return + migrateDataStoreToRoom(context, dataStore, dao, json) + migrated = true } + } + + suspend fun getAll(): List { + ensureMigrated() + return dao.getAll().map { it.toDomain() } + } + + fun getAllFlow(): Flow> = flow { + ensureMigrated() + emit(dao.getAll().map { it.toDomain() }) + } suspend fun insertAll(newRecords: List) { - val current = loadAll().toMutableList() - val existingIds = cachedIds ?: current.map { it.id }.toSet() - val toAdd = newRecords.filter { it.id !in existingIds } - if (toAdd.isEmpty()) return - current.addAll(toAdd) - persist(current) + ensureMigrated() + if (newRecords.isEmpty()) return + val entities = newRecords.map { it.toEntity() } + dao.insertAll(entities) } - suspend fun getLatest(): UsageRecord? = loadAll().maxByOrNull { it.timeCreated } + suspend fun getLatest(): UsageRecord? { + ensureMigrated() + return dao.getLatest()?.toDomain() + } suspend fun getAllIds(): Set { - cachedIds?.let { return it } - return loadAll().map { it.id }.toSet() + ensureMigrated() + return dao.getAllIds().toSet() } /** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */ suspend fun deleteByWorkspaceId(workspaceId: String) { - val current = loadAll().toMutableList() - val removed = current.removeAll { it.workspaceId == workspaceId } - if (removed) persist(current) + ensureMigrated() + dao.deleteByWorkspaceId(workspaceId) } - suspend fun count(): Int = loadAll().size + suspend fun count(): Int { + ensureMigrated() + return dao.count() + } /** 按 workspaceId 统计记录数。 */ - suspend fun count(workspaceId: String): Int = loadAll().count { it.workspaceId == workspaceId } + suspend fun count(workspaceId: String): Int { + ensureMigrated() + return dao.countByWorkspace(workspaceId) + } /** 获取过滤后的原始记录列表(供图表等聚合使用) */ suspend fun getRecords( @@ -95,24 +85,20 @@ class UsageCache( fromTs: Long? = null, toTs: Long? = null ): List { - return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs) + ensureMigrated() + val entities = when { + fromTs != null && toTs != null -> dao.getByWorkspaceAndTime(workspaceId, fromTs, toTs) + fromTs != null -> dao.getByWorkspaceFrom(workspaceId, fromTs) + toTs != null -> dao.getByWorkspaceTo(workspaceId, toTs) + else -> dao.getByWorkspace(workspaceId) + } + return entities.map { it.toDomain() } } /** 获取所有不同模型名称 */ suspend fun getDistinctModels(workspaceId: String): List { - return loadAll() - .filter { it.workspaceId == workspaceId } - .map { it.model } - .distinct() - .sorted() - } - - /** 按时间范围过滤记录 */ - private fun filterByTime(records: List, fromTs: Long?, toTs: Long?): List { - var result = records - if (fromTs != null) result = result.filter { it.timeCreated >= fromTs } - if (toTs != null) result = result.filter { it.timeCreated <= toTs } - return result + ensureMigrated() + return dao.getDistinctModels(workspaceId) } suspend fun getStatsByModel( @@ -120,14 +106,15 @@ class UsageCache( fromTs: Long? = null, toTs: Long? = null ): List { - return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs) + val records = getRecords(workspaceId, fromTs, toTs) + return records .groupBy { it.model } - .map { (model, records) -> + .map { (model, recs) -> ModelStats( model = model, - totalTokens = records.sumOf { it.totalTokens }, - totalCost = records.sumOf { it.cost }, - count = records.size + totalTokens = recs.sumOf { it.totalTokens }, + totalCost = recs.sumOf { it.cost }, + count = recs.size ) } .sortedByDescending { it.totalTokens } @@ -139,14 +126,15 @@ class UsageCache( fromTs: Long? = null, toTs: Long? = null ): List { - return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs) + val records = getRecords(workspaceId, fromTs, toTs) + return records .groupBy { it.timeCreated / 86_400_000L * 86_400_000L } - .map { (dayTs, records) -> + .map { (dayTs, recs) -> DailyStats( dayTs = dayTs, - totalTokens = records.sumOf { it.totalTokens }, - totalCost = records.sumOf { it.cost }, - count = records.size + totalTokens = recs.sumOf { it.totalTokens }, + totalCost = recs.sumOf { it.cost }, + count = recs.size ) } .sortedByDescending { it.dayTs } @@ -158,7 +146,7 @@ class UsageCache( fromTs: Long? = null, toTs: Long? = null ): OverviewStats? { - val records = filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs) + val records = getRecords(workspaceId, fromTs, toTs) if (records.isEmpty()) return null return OverviewStats( totalTokens = records.sumOf { it.inputTokens + it.cacheReadTokens + it.outputTokens }, @@ -173,12 +161,6 @@ class UsageCache( cacheWrite1hTokens = records.sumOf { it.cacheWrite1hTokens } ) } - - private suspend fun persist(list: List) { - val raw = json.encodeToString(ListSerializer(UsageRecord.serializer()), list) - dataStore.edit { it[cacheKey] = raw } - invalidateCache() - } } @Serializable @@ -234,7 +216,7 @@ data class OverviewStats( val cacheWriteTokens: Long get() = cacheWrite5mTokens + cacheWrite1hTokens } -/** DataStore 委托 */ -val Context.usageCacheDataStore: DataStore by preferencesDataStore( +/** DataStore 委托 — 保留用于数据迁移,迁移完成后旧 key 会被清除 */ +val Context.usageCacheDataStore: DataStore by androidx.datastore.preferences.preferencesDataStore( name = "usage_cache" ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/local/UsageDao.kt b/app/src/main/java/com/rainy/token/data/local/UsageDao.kt new file mode 100644 index 0000000..82f1ae8 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/local/UsageDao.kt @@ -0,0 +1,52 @@ +package com.rainy.token.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface UsageDao { + + @Query("SELECT * FROM usage_records") + suspend fun getAll(): List + + @Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId ORDER BY timeCreated DESC") + suspend fun getByWorkspace(workspaceId: String): List + + @Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs AND timeCreated <= :toTs ORDER BY timeCreated DESC") + suspend fun getByWorkspaceAndTime(workspaceId: String, fromTs: Long, toTs: Long): List + + @Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs ORDER BY timeCreated DESC") + suspend fun getByWorkspaceFrom(workspaceId: String, fromTs: Long): List + + @Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated <= :toTs ORDER BY timeCreated DESC") + suspend fun getByWorkspaceTo(workspaceId: String, toTs: Long): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(records: List): List + + @Query("DELETE FROM usage_records WHERE workspaceId = :workspaceId") + suspend fun deleteByWorkspaceId(workspaceId: String): Int + + @Query("SELECT COUNT(*) FROM usage_records") + suspend fun count(): Int + + @Query("SELECT COUNT(*) FROM usage_records WHERE workspaceId = :workspaceId") + suspend fun countByWorkspace(workspaceId: String): Int + + @Query("SELECT DISTINCT model FROM usage_records WHERE workspaceId = :workspaceId ORDER BY model ASC") + suspend fun getDistinctModels(workspaceId: String): List + + @Query("SELECT * FROM usage_records ORDER BY timeCreated DESC LIMIT 1") + suspend fun getLatest(): UsageRecordEntity? + + @Query("SELECT id FROM usage_records") + suspend fun getAllIds(): List + + @Query("SELECT id FROM usage_records WHERE workspaceId = :workspaceId") + suspend fun getIdsByWorkspace(workspaceId: String): List + + @Query("SELECT MAX(timeCreated) FROM usage_records WHERE workspaceId = :workspaceId") + suspend fun getMaxTimeCreated(workspaceId: String): Long? +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/local/UsageDatabase.kt b/app/src/main/java/com/rainy/token/data/local/UsageDatabase.kt new file mode 100644 index 0000000..3730a36 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/local/UsageDatabase.kt @@ -0,0 +1,80 @@ +package com.rainy.token.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +@Database( + entities = [UsageRecordEntity::class], + version = 1, + exportSchema = false +) +abstract class UsageDatabase : RoomDatabase() { + abstract fun usageDao(): UsageDao + + companion object { + @Volatile + private var INSTANCE: UsageDatabase? = null + + fun getInstance(context: Context): UsageDatabase { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + UsageDatabase::class.java, + "usage_database" + ).build().also { INSTANCE = it } + } + } + } +} + +/** + * One-time migration from old DataStore JSON cache to Room. + * + * Called by [UsageCache] on first access. Reads the old JSON blob from DataStore, + * deserializes it, inserts into Room, then clears the old key. + * + * A SharedPreferences flag prevents re-running if the old data was already consumed + * (e.g. if the DataStore was cleared but the flag was set). + * + * @return true if migration ran and inserted records, false if no migration was needed. + */ +suspend fun migrateDataStoreToRoom( + context: Context, + dataStore: DataStore, + dao: UsageDao, + json: Json +): Boolean { + val prefs = context.getSharedPreferences("usage_cache_migration", Context.MODE_PRIVATE) + if (prefs.getBoolean("migrated_to_room", false)) return false + + val cacheKey = stringPreferencesKey("usage_cache_v1") + val raw = dataStore.data.map { it[cacheKey] }.first() ?: run { + // No old data — mark as migrated so we never check again + prefs.edit().putBoolean("migrated_to_room", true).apply() + return false + } + + val records = runCatching { + json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw) + }.getOrDefault(emptyList()) + + if (records.isNotEmpty()) { + val entities = records.map { it.toEntity() } + dao.insertAll(entities) + } + + // Mark migrated and clear old key to free space + prefs.edit().putBoolean("migrated_to_room", true).apply() + dataStore.edit { it.remove(cacheKey) } + return records.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/local/UsageRecordEntity.kt b/app/src/main/java/com/rainy/token/data/local/UsageRecordEntity.kt new file mode 100644 index 0000000..b04c772 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/local/UsageRecordEntity.kt @@ -0,0 +1,81 @@ +package com.rainy.token.data.local + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room Entity for usage records. + * + * Mirrors the fields of [UsageRecord] exactly. + * Indexes on workspaceId + timeCreated for fast time-range queries. + */ +@Entity( + tableName = "usage_records", + indices = [ + Index(value = ["workspaceId", "timeCreated"]), + Index(value = ["workspaceId", "model"]), + Index(value = ["id"], unique = true) + ] +) +data class UsageRecordEntity( + @PrimaryKey + val id: String, + val workspaceId: String, + val timeCreated: Long, + val timeUpdated: Long, + val model: String, + val provider: String, + val inputTokens: Long, + val outputTokens: Long, + val reasoningTokens: Long, + val cacheReadTokens: Long, + val cacheWrite5mTokens: Long = 0, + val cacheWrite1hTokens: Long = 0, + val cost: Long, + val keyId: String, + val sessionId: String, + val enrichmentPlan: String = "" +) { + /** Convert Entity → domain model */ + fun toDomain(): UsageRecord = UsageRecord( + id = id, + workspaceId = workspaceId, + timeCreated = timeCreated, + timeUpdated = timeUpdated, + model = model, + provider = provider, + inputTokens = inputTokens, + outputTokens = outputTokens, + reasoningTokens = reasoningTokens, + cacheReadTokens = cacheReadTokens, + cacheWrite5mTokens = cacheWrite5mTokens, + cacheWrite1hTokens = cacheWrite1hTokens, + cost = cost, + keyId = keyId, + sessionId = sessionId, + enrichmentPlan = enrichmentPlan + ) + + val totalTokens: Long get() = inputTokens + outputTokens + reasoningTokens +} + +/** Convert domain model → Entity */ +fun UsageRecord.toEntity(): UsageRecordEntity = UsageRecordEntity( + id = id, + workspaceId = workspaceId, + timeCreated = timeCreated, + timeUpdated = timeUpdated, + model = model, + provider = provider, + inputTokens = inputTokens, + outputTokens = outputTokens, + reasoningTokens = reasoningTokens, + cacheReadTokens = cacheReadTokens, + cacheWrite5mTokens = cacheWrite5mTokens, + cacheWrite1hTokens = cacheWrite1hTokens, + cost = cost, + keyId = keyId, + sessionId = sessionId, + enrichmentPlan = enrichmentPlan +) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 5de292b..168ae68 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -6,6 +6,8 @@ import com.rainy.token.data.cache.balanceCacheDataStore import com.rainy.token.data.remote.DeepSeekApi import com.rainy.token.data.local.SecureStorage import com.rainy.token.data.local.UsageCache +import com.rainy.token.data.local.UsageDao +import com.rainy.token.data.local.UsageDatabase import com.rainy.token.data.local.usageCacheDataStore import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.DeepSeekRepository @@ -78,12 +80,26 @@ object NetworkModule { // ---- Room ---- + @Provides + @Singleton + fun provideUsageDatabase( + @ApplicationContext context: Context + ): UsageDatabase = UsageDatabase.getInstance(context) + + @Provides + @Singleton + fun provideUsageDao( + database: UsageDatabase + ): UsageDao = database.usageDao() + @Provides @Singleton fun provideUsageCache( + @ApplicationContext context: Context, + dao: UsageDao, @Named(DataStoreQualifiers.USAGE_CACHE) dataStore: DataStore, json: Json - ): UsageCache = UsageCache(dataStore, json) + ): UsageCache = UsageCache(context, dao, dataStore, json) // ---- Repositories ---- diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3b7355..a8a276e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,9 @@ workRuntimeKtx = "2.9.1" hilt = "2.59.2" hiltNavigationCompose = "1.2.0" +# Room +room = "2.7.1" + # Test junit = "4.13.2" junitVersion = "1.2.1" @@ -82,6 +85,11 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +# Room +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + # Test junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }