fix: 凭据变更后刷新状态并防止旧请求回写 (#2)

## 修复内容

### Issue #2: 凭据保存/删除后返回设置页配置状态不刷新

**核心修复:**
- Dashboard 并发竞态:reloadLocalState() 改为只更新 credentialState,不再通过 loadFromCache() 重建卡片,避免覆盖 refresh() 写入的最新余额/错误状态
- ServiceDetail 凭据刷新:reloadCredentialState() 增加凭据指纹检测(credentialFingerprint),区分凭据替换/新增/删除三种场景,正确清除旧缓存
- refresh() 加 generation 标记,防止旧请求响应覆盖新数据
- 宽屏布局刷新:Expanded 模式下 ServiceDetail 面板切换时正确触发凭据状态刷新

**安全修复:**
- 移除公开提交的 debug.keystore,恢复 AGP 默认签名行为
- 凭据指纹架构:SHA-256 不可逆指纹 + cacheIdentityFingerprint + sameRefreshLineage,支持 Codex token 轮换不误清缓存
- RefreshWriteSession + CredentialSnapshot 原子提交架构

**测试:**
- 新增 16+ 个单元测试覆盖凭据指纹、分类逻辑、四个场景
- 总计 78+ 个用例全部通过

Refs #2
This commit is contained in:
WaterRain 2026-07-27 11:40:47 +08:00 committed by GitHub
parent 74a4e3d494
commit 2732b138ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1621 additions and 260 deletions

View File

@ -6,8 +6,10 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.rainy.token.data.repository.RefreshWriteSession
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.MapSerializer
@ -31,35 +33,46 @@ class BalanceCache(
suspend fun getAll(): Map<ServiceType, CachedBalance> {
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap()
return runCatching {
val map = json.decodeFromString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
raw
)
map
}.getOrDefault(emptyMap())
return decode(raw)
}
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
suspend fun put(service: ServiceType, balance: ServiceBalance) {
// 在 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
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
session.stageBalance(service, balance)
return
}
putCached(
service = service,
cachedBalance = CachedBalance(
balance = balance,
fetchedAt = System.currentTimeMillis()
)
)
}
/** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */
internal suspend fun putCached(service: ServiceType, cachedBalance: CachedBalance) {
dataStore.edit { prefs ->
val updated = decode(prefs[cacheKey]).toMutableMap()
updated[service] = cachedBalance
prefs[cacheKey] = encode(updated)
}
}
/** 删除单个服务缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */
suspend fun remove(service: ServiceType) {
dataStore.edit { prefs ->
val current = decode(prefs[cacheKey])
if (service !in current) return@edit
val updated = current.toMutableMap()
updated.remove(service)
if (updated.isEmpty()) {
prefs.remove(cacheKey)
} else {
prefs[cacheKey] = encode(updated)
}
}
}
@ -67,6 +80,22 @@ class BalanceCache(
dataStore.edit { it.remove(cacheKey) }
}
private fun decode(raw: String?): Map<ServiceType, CachedBalance> {
if (raw == null) return emptyMap()
return runCatching {
json.decodeFromString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
raw
)
}.getOrDefault(emptyMap())
}
private fun encode(value: Map<ServiceType, CachedBalance>): String =
json.encodeToString(
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
value
)
companion object {
val DefaultJson = Json {
ignoreUnknownKeys = true
@ -84,4 +113,4 @@ data class CachedBalance(
/** 顶层 DataStore 委托。文件名对应计划 7.1。 */
val Context.balanceCacheDataStore: DataStore<Preferences> by preferencesDataStore(
name = "balance_cache"
)
)

View File

@ -1,26 +1,278 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.local.SecureStorage
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.serialization.builtins.serializer
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* 凭据的统一读写入口。封装 SecureStorage 的 key 命名规则(用 [ServiceType.storageKey]),
* 把 JSON 反序列化成密封类 [Credential]。
* 凭据的统一读写入口。
*
* 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得
* [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。
*/
@Singleton
class CredentialRepository @Inject constructor(
private val secureStorage: SecureStorage
private val secureStorage: SecureStorage,
private val balanceCache: BalanceCache
) {
internal data class CredentialSnapshot(
val credential: Credential,
val revision: Long,
val fingerprint: String
) {
val service: ServiceType get() = credential.service
}
internal data class LocalState(
val status: CredentialStatus,
val fingerprint: String?,
val cachedBalance: CachedBalance?
)
private data class CacheRollbackEntry(
val testedFingerprint: String,
val previousIdentity: String?,
val cachedBalance: CachedBalance?
)
private val mutationMutex = Mutex()
private val revisions = mutableMapOf<ServiceType, Long>()
private val cacheRollbackEntries = mutableMapOf<ServiceType, CacheRollbackEntry>()
private fun keyFor(service: ServiceType): String = "credential_${service.storageKey}"
suspend fun save(credential: Credential) {
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
session.stageCredential(credential)
return
}
mutationMutex.withLock {
val service = credential.service
val current = getUnlocked(service)
val currentIdentity = cacheIdentityFingerprint(current)
val newIdentity = cacheIdentityFingerprint(credential)
val cacheIdentityChanged = currentIdentity != newIdentity
bumpRevision(service)
if (cacheIdentityChanged) {
cacheRollbackEntries[service] = CacheRollbackEntry(
testedFingerprint = credentialFingerprint(credential)!!,
previousIdentity = currentIdentity,
cachedBalance = if (current != null) balanceCache.get(service) else null
)
balanceCache.remove(service)
} else {
cacheRollbackEntries.remove(service)
}
putUnlocked(credential)
}
}
suspend fun get(service: ServiceType): Credential? {
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
if (session.snapshot.service == service) {
return session.credentialForRead()
}
}
return mutationMutex.withLock { getUnlocked(service) }
}
internal suspend fun readLocalState(service: ServiceType): LocalState =
mutationMutex.withLock {
val credential = getUnlocked(service)
localStateOf(service, credential, balanceCache.get(service))
}
internal suspend fun readLocalStates(): Map<ServiceType, LocalState> =
mutationMutex.withLock {
val cached = balanceCache.getAll()
ServiceType.entries.associateWith { service ->
val credential = getUnlocked(service)
localStateOf(service, credential, cached[service])
}
}
suspend fun remove(service: ServiceType) {
mutationMutex.withLock {
bumpRevision(service)
cacheRollbackEntries.remove(service)
balanceCache.remove(service)
secureStorage.remove(keyFor(service))
}
}
/**
* 仅当测试期间凭据 revision 与认证指纹都未变化时恢复旧凭据。
* 若替换凭据时清除了旧账户缓存,这里会一并恢复原缓存和原 fetchedAt。
*/
internal suspend fun restoreIfCurrent(
testedSnapshot: CredentialSnapshot,
previous: Credential?
): Boolean = mutationMutex.withLock {
val service = testedSnapshot.service
val current = getUnlocked(service) ?: return@withLock false
if (
!snapshotMatches(
snapshotRevision = testedSnapshot.revision,
snapshotFingerprint = testedSnapshot.fingerprint,
currentRevision = revisionFor(service),
currentFingerprint = credentialFingerprint(current)
)
) return@withLock false
val currentIdentity = cacheIdentityFingerprint(current)
val previousIdentity = cacheIdentityFingerprint(previous)
val identityChanged = currentIdentity != previousIdentity
val rollbackEntry = cacheRollbackEntries[service]?.takeIf {
it.testedFingerprint == testedSnapshot.fingerprint &&
it.previousIdentity == previousIdentity
}
bumpRevision(service)
if (identityChanged) {
balanceCache.remove(service)
}
if (previous == null) {
secureStorage.remove(keyFor(service))
} else {
require(previous.service == service) { "回滚凭据服务不匹配" }
putUnlocked(previous)
}
if (identityChanged) {
rollbackEntry?.cachedBalance?.let { balanceCache.putCached(service, it) }
}
cacheRollbackEntries.remove(service)
true
}
internal suspend fun snapshot(service: ServiceType): CredentialSnapshot? =
mutationMutex.withLock {
val credential = getUnlocked(service) ?: return@withLock null
CredentialSnapshot(
credential = credential,
revision = revisionFor(service),
fingerprint = credentialFingerprint(credential)!!
)
}
/**
* 提交 [RefreshWriteSession] 中暂存的写入。
*
* 完全只读的请求忽略同一凭据的 revision-only 变化,但成功和失败都必须确认认证
* 指纹仍与请求起点一致,避免把旧账户结果发布到用户刚替换的新凭据。存在写入时
* 通常要求快照完全匹配;认证字段合法轮换时允许越过同账户纯元数据版本变化。
*/
internal suspend fun commit(
session: RefreshWriteSession,
includeBalance: Boolean
): Boolean = mutationMutex.withLock {
val pendingCredential = session.stagedCredential()
val pendingBalance = session.stagedBalance().takeIf { includeBalance }
val hasPendingWrites = pendingCredential != null || pendingBalance != null
val snapshot = session.snapshot
val current = getUnlocked(snapshot.service) ?: return@withLock false
val currentFingerprint = credentialFingerprint(current)
// 一键激活等只读会话不应被同凭据刷新造成的 revision 变化误伤;但凭据真正
// 替换/删除后,旧账户的成功响应和失败信息都必须丢弃。
if (!hasPendingWrites) {
return@withLock currentFingerprint == snapshot.fingerprint
}
val snapshotStillCurrent = snapshotMatches(
snapshotRevision = snapshot.revision,
snapshotFingerprint = snapshot.fingerprint,
currentRevision = revisionFor(snapshot.service),
currentFingerprint = currentFingerprint
)
val canMergeRotatedCredential = pendingCredential != null &&
credentialFingerprint(pendingCredential) != snapshot.fingerprint &&
currentFingerprint == snapshot.fingerprint &&
sameRefreshLineage(snapshot.credential, pendingCredential)
if (!snapshotStillCurrent && !canMergeRotatedCredential) return@withLock false
val finalCredential = pendingCredential ?: current
val sameLineage = sameRefreshLineage(current, finalCredential)
val cacheIdentityChanged =
cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) &&
!sameLineage
bumpRevision(snapshot.service)
if (cacheIdentityChanged) {
balanceCache.remove(snapshot.service)
}
if (pendingCredential != null) {
putUnlocked(pendingCredential)
}
if (pendingBalance != null) {
balanceCache.put(snapshot.service, pendingBalance)
cacheRollbackEntries.remove(snapshot.service)
}
true
}
suspend fun statusFor(service: ServiceType): CredentialStatus {
val credential = get(service) ?: return CredentialStatus(
service = service,
state = CredentialStatus.State.NOT_CONFIGURED,
lastVerifiedAt = 0L
)
return CredentialStatus(
service = service,
state = determineCredentialState(
credential.lastVerifiedAt,
System.currentTimeMillis()
),
lastVerifiedAt = credential.lastVerifiedAt
)
}
suspend fun statusForAll(): List<CredentialStatus> =
ServiceConfigProvider.all().map { statusFor(it.type) }
private fun localStateOf(
service: ServiceType,
credential: Credential?,
cached: CachedBalance?
): LocalState {
val status = if (credential == null) {
CredentialStatus(
service = service,
state = CredentialStatus.State.NOT_CONFIGURED,
lastVerifiedAt = 0L
)
} else {
CredentialStatus(
service = service,
state = determineCredentialState(
credential.lastVerifiedAt,
System.currentTimeMillis()
),
lastVerifiedAt = credential.lastVerifiedAt
)
}
return LocalState(
status = status,
fingerprint = credentialFingerprint(credential),
cachedBalance = cached
)
}
private suspend fun putUnlocked(credential: Credential) {
secureStorage.put(
key = keyFor(credential.service),
value = credential,
@ -28,39 +280,161 @@ class CredentialRepository @Inject constructor(
)
}
suspend fun get(service: ServiceType): Credential? =
private suspend fun getUnlocked(service: ServiceType): Credential? =
secureStorage.get(
key = keyFor(service),
serializer = Credential.serializer()
)
suspend fun remove(service: ServiceType) {
secureStorage.remove(keyFor(service))
private fun revisionFor(service: ServiceType): Long = revisions[service] ?: 0L
private fun bumpRevision(service: ServiceType) {
revisions[service] = revisionFor(service) + 1L
}
/**
* 读取并转换为 UI 用的 [CredentialStatus]。未配置/已删除都返回 NOT_CONFIGURED。
*/
suspend fun statusFor(service: ServiceType): CredentialStatus {
val credential = get(service) ?: return CredentialStatus(
service = service,
state = CredentialStatus.State.NOT_CONFIGURED,
lastVerifiedAt = 0L
)
val now = System.currentTimeMillis()
// 简单启发:最近 7 天内有验证 → OK;否则按 lastVerifiedAt 是否为 0 判断
val state = when {
credential.lastVerifiedAt == 0L -> CredentialStatus.State.WARNING
now - credential.lastVerifiedAt > 7L * 24 * 3600 * 1000 -> CredentialStatus.State.WARNING
companion object {
internal fun determineCredentialState(
lastVerifiedAt: Long,
now: Long
): CredentialStatus.State = when {
lastVerifiedAt == 0L -> CredentialStatus.State.WARNING
now - lastVerifiedAt > 7L * 24 * 3600 * 1000 -> CredentialStatus.State.WARNING
else -> CredentialStatus.State.OK
}
return CredentialStatus(
service = service,
state = state,
lastVerifiedAt = credential.lastVerifiedAt
)
}
suspend fun statusForAll(): List<CredentialStatus> =
ServiceConfigProvider.all().map { statusFor(it.type) }
}
/**
* 判断认证更新是否来自同一条合法刷新链。Codex 的 refresh 响应由旧凭据 copy
* 产生,因此 accountId(即使为空)必须保持一致;其他类型要求余额账户身份一致。
*/
internal fun sameRefreshLineage(
original: Credential,
updated: Credential
): Boolean = when {
original is Credential.CodexCredential && updated is Credential.CodexCredential ->
original.service == updated.service && original.accountId == updated.accountId
else -> cacheIdentityFingerprint(original) == cacheIdentityFingerprint(updated)
}
/**
* 余额缓存所属账户的不可逆标识。Codex 的短期 Token 轮换不会改变账户标识;
* SessionCredential 的触发用 API Key 也不会影响余额账户。
*/
internal fun cacheIdentityFingerprint(credential: Credential?): String? {
if (credential == null) return null
val material = buildString {
field("service", credential.service.storageKey)
when (credential) {
is Credential.ApiKeyCredential -> {
field("type", "api-key")
field("key", credential.key)
}
is Credential.SessionCredential -> {
field("type", "session")
field("token", credential.token)
field("authCookie", credential.authCookie)
field("workspaceId", credential.workspaceId)
field("ollamaCookie", credential.ollamaCookie)
credential.cookies.sortedForFingerprint().forEachIndexed { index, cookie ->
field("cookie[$index].name", cookie.name)
field("cookie[$index].value", cookie.value)
field("cookie[$index].domain", cookie.domain)
field("cookie[$index].path", cookie.path)
}
}
is Credential.CodexCredential -> {
field("type", "codex")
if (credential.accountId.isNotBlank()) {
field("accountId", credential.accountId)
} else {
field("accessToken", credential.accessToken)
field("refreshToken", credential.refreshToken)
}
}
}
}
return sha256(material)
}
internal fun snapshotMatches(
snapshotRevision: Long,
snapshotFingerprint: String,
currentRevision: Long,
currentFingerprint: String?
): Boolean =
snapshotRevision == currentRevision && snapshotFingerprint == currentFingerprint
/** 对认证相关字段生成不可逆 SHA-256 指纹;不包含 lastVerifiedAt。 */
internal fun credentialFingerprint(credential: Credential?): String? {
if (credential == null) return null
val material = buildString {
field("service", credential.service.storageKey)
when (credential) {
is Credential.ApiKeyCredential -> {
field("type", "api-key")
field("key", credential.key)
}
is Credential.SessionCredential -> {
field("type", "session")
field("token", credential.token)
field("authCookie", credential.authCookie)
field("workspaceId", credential.workspaceId)
field("ollamaCookie", credential.ollamaCookie)
field("apiKey", credential.apiKey)
credential.cookies.sortedForFingerprint().forEachIndexed { index, cookie ->
field("cookie[$index].name", cookie.name)
field("cookie[$index].value", cookie.value)
field("cookie[$index].domain", cookie.domain)
field("cookie[$index].path", cookie.path)
field("cookie[$index].expiresAt", cookie.expiresAt?.toString())
field("cookie[$index].secure", cookie.isSecure.toString())
field("cookie[$index].httpOnly", cookie.isHttpOnly.toString())
}
}
is Credential.CodexCredential -> {
field("type", "codex")
field("accessToken", credential.accessToken)
field("refreshToken", credential.refreshToken)
field("accountId", credential.accountId)
}
}
}
return sha256(material)
}
private fun List<com.rainy.token.domain.model.CookieEntry>.sortedForFingerprint() =
sortedWith(
compareBy(
{ it.name },
{ it.domain.orEmpty() },
{ it.path.orEmpty() },
{ it.value }
)
)
private fun StringBuilder.field(name: String, value: String?) {
append(name)
append('=')
if (value == null) {
append("-1:")
} else {
append(value.length)
append(':')
append(value)
}
append(';')
}
private fun sha256(value: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
val chars = CharArray(digest.size * 2)
val hex = "0123456789abcdef"
digest.forEachIndexed { index, byte ->
val unsigned = byte.toInt() and 0xff
chars[index * 2] = hex[unsigned ushr 4]
chars[index * 2 + 1] = hex[unsigned and 0x0f]
}
return String(chars)
}
}
}

View File

@ -0,0 +1,75 @@
package com.rainy.token.data.repository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
/**
* 单次余额/用量请求的延迟写入会话。
*
* Repository 仍可按原有顺序调用 CredentialRepository.save() 与 BalanceCache.put(),
* 但在该上下文中写入只会暂存。请求结束后由 CredentialRepository 在同一互斥区内
* 校验凭据快照并一次性提交,避免旧请求覆盖刚保存或删除的新凭据。
*/
internal class RefreshWriteSession(
val snapshot: CredentialRepository.CredentialSnapshot
) : AbstractCoroutineContextElement(Key) {
companion object Key : CoroutineContext.Key<RefreshWriteSession>
private val stateLock = Any()
private var pendingCredential: Credential? = null
private var pendingBalance: ServiceBalance? = null
fun stageCredential(credential: Credential) {
require(credential.service == snapshot.service) {
"刷新会话服务不匹配:expected=${snapshot.service}, actual=${credential.service}"
}
synchronized(stateLock) {
pendingCredential = mergeCredential(pendingCredential, credential)
}
}
fun stageBalance(service: ServiceType, balance: ServiceBalance) {
require(service == snapshot.service && balance.service == snapshot.service) {
"刷新缓存服务不匹配:expected=${snapshot.service}, key=$service, balance=${balance.service}"
}
synchronized(stateLock) {
pendingBalance = balance
}
}
/** 同一刷新会话内始终读取快照或已暂存的新凭据,保证重试不会退回旧 Token。 */
fun credentialForRead(): Credential = synchronized(stateLock) {
pendingCredential ?: snapshot.credential
}
fun stagedCredential(): Credential? = synchronized(stateLock) { pendingCredential }
fun stagedBalance(): ServiceBalance? = synchronized(stateLock) { pendingBalance }
private fun mergeCredential(current: Credential?, candidate: Credential): Credential {
if (current == null) return candidate
val baseFingerprint = snapshot.fingerprint
val currentChanged = CredentialRepository.credentialFingerprint(current) != baseFingerprint
val candidateChanged = CredentialRepository.credentialFingerprint(candidate) != baseFingerprint
// Codex 可能先暂存轮换后的 token,随后又用请求开始时的旧 credential
// 仅更新 lastVerifiedAt。此时必须保留已轮换的认证字段,不能退回旧 token。
val selected = when {
currentChanged && !candidateChanged -> current
!currentChanged && candidateChanged -> candidate
else -> candidate
}
return selected.withLastVerifiedAt(maxOf(current.lastVerifiedAt, candidate.lastVerifiedAt))
}
}
private fun Credential.withLastVerifiedAt(value: Long): Credential = when (this) {
is Credential.ApiKeyCredential -> copy(lastVerifiedAt = value)
is Credential.SessionCredential -> copy(lastVerifiedAt = value)
is Credential.CodexCredential -> copy(lastVerifiedAt = value)
}

View File

@ -9,6 +9,9 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")
/** 限流(429 Too Many Requests) */
class RateLimited(val retryAfterSeconds: Long? = null) :
RepositoryError("请求过于频繁${retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}")
@ -29,4 +32,4 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
"未知错误" + (cause?.let { ": ${it::class.simpleName}: ${it.message ?: "(no message)"}" } ?: ""),
cause
)
}
}

View File

@ -2,63 +2,128 @@ package com.rainy.token.domain.usecase
import com.rainy.token.data.repository.CodexRepository
import com.rainy.token.data.repository.CommandCodeGoRepository
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.DeepSeekRepository
import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.RefreshWriteSession
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.retryOnTransientError
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
/**
* 唯一的 UseCase。计划架构补充说明:
* - 内部按 ServiceType 分发到对应 Repository
* - 现阶段实现:DeepSeek(REST API) + OpenCode Go(OkHttp 抓 dashboard)+ CommandCode Go(JSON API)
* + Codex / ChatGPT Plus(wham usage API)+ Ollama Pro(Cookie 抓 settings HTML)
* - **使用 Provider 注入**——规避 KSP 2.x 在多个 @Inject constructor Repository
* 注入同一 UseCase 时的"could not be resolved"误报(KSP 已知 issue)。
* Provider 让 Hilt 推迟创建 Repository 实例到第一次 .get() 时,KSP 不需要在
* 编译期解析所有构造器签名。
* - **重试策略**:对 Network 和 5xx ServerError 自动指数退避重试(最多 2 次),
* 401/403/429/ParseError 不重试。
* 唯一的余额刷新 UseCase。
*
* Repository 中的凭据/缓存写入先暂存;请求结束后校验起始凭据快照仍为当前版本才提交,
* 避免旧请求覆盖用户刚保存或删除的新凭据。
*/
class RefreshBalanceUseCase @Inject constructor(
private val credentialRepository: CredentialRepository,
private val deepSeekRepositoryProvider: Provider<DeepSeekRepository>,
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
private val codexRepositoryProvider: Provider<CodexRepository>,
private val ollamaRepositoryProvider: Provider<OllamaRepository>
) {
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> = when (service) {
ServiceType.DEEPSEEK -> retryOnTransientError { deepSeekRepositoryProvider.get().fetchBalance() }
ServiceType.OPENCODE_GO -> retryOnTransientError { openCodeGoRepositoryProvider.get().fetchBalance() }
ServiceType.COMMANDCODE_GO -> retryOnTransientError { commandCodeGoRepositoryProvider.get().fetchBalance() }
ServiceType.CODEX -> retryOnTransientError { codexRepositoryProvider.get().fetchBalance() }
ServiceType.OLLAMA -> retryOnTransientError { ollamaRepositoryProvider.get().fetchBalance() }
}
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> =
withCredentialSession(service) {
when (service) {
ServiceType.DEEPSEEK -> retryOnTransientError {
deepSeekRepositoryProvider.get().fetchBalance()
}
ServiceType.OPENCODE_GO -> retryOnTransientError {
openCodeGoRepositoryProvider.get().fetchBalance()
}
ServiceType.COMMANDCODE_GO -> retryOnTransientError {
commandCodeGoRepositoryProvider.get().fetchBalance()
}
ServiceType.CODEX -> retryOnTransientError {
codexRepositoryProvider.get().fetchBalance()
}
ServiceType.OLLAMA -> retryOnTransientError {
ollamaRepositoryProvider.get().fetchBalance()
}
}
}
/** 获取 Codex 可用模型列表 */
suspend fun fetchCodexModels(): Result<List<String>> =
codexRepositoryProvider.get().fetchModels()
/** 一键激活 Codex 用量:向 ChatGPT API 发送请求,返回完整响应体 */
suspend fun triggerCodexUsage(model: String): Result<String> =
codexRepositoryProvider.get().triggerUsage(model)
withCredentialSession(ServiceType.CODEX) {
codexRepositoryProvider.get().triggerUsage(model)
}
/** 获取 OpenCode Go 可用模型列表 */
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
openCodeGoRepositoryProvider.get().fetchModels()
/** 一键激活 OpenCode Go 用量 */
suspend fun triggerOpenCodeGoUsage(model: String): Result<String> =
openCodeGoRepositoryProvider.get().triggerUsage(model)
withCredentialSession(ServiceType.OPENCODE_GO) {
openCodeGoRepositoryProvider.get().triggerUsage(model)
}
/** 获取 Ollama Cloud 可用模型列表 */
suspend fun fetchOllamaModels(): Result<List<String>> =
ollamaRepositoryProvider.get().fetchModels()
/** 一键激活 Ollama Cloud 用量 */
suspend fun triggerOllamaUsage(model: String): Result<String> =
ollamaRepositoryProvider.get().triggerUsage(model)
}
withCredentialSession(ServiceType.OLLAMA) {
ollamaRepositoryProvider.get().triggerUsage(model)
}
private suspend fun <T> withCredentialSession(
service: ServiceType,
block: suspend () -> Result<T>
): Result<T> {
val snapshot = credentialRepository.snapshot(service)
?: return Result.failure(RepositoryError.InvalidCredential())
val session = RefreshWriteSession(snapshot)
val result = try {
withContext(session) { block() }
} catch (cancelled: CancellationException) {
// Codex refresh_token 是单次轮换的:服务端可能已经作废旧 token,而新 token
// 已暂存在 session。即使 Widget 超时取消,也必须先在不可取消区提交凭据;
// 余额不提交,因为原业务请求没有正常完成。
commitIgnoringCancellation(session, includeBalance = false)
throw cancelled
} catch (error: Throwable) {
Result.failure(RepositoryError.Unknown(error))
}
val committed = try {
withContext(NonCancellable) {
credentialRepository.commit(
session = session,
includeBalance = result.isSuccess
)
}
} catch (error: Throwable) {
return Result.failure(RepositoryError.Unknown(error))
}
return if (committed) {
result
} else {
Result.failure(RepositoryError.CredentialChanged())
}
}
private suspend fun commitIgnoringCancellation(
session: RefreshWriteSession,
includeBalance: Boolean
) {
withContext(NonCancellable) {
try {
credentialRepository.commit(session, includeBalance)
} catch (_: Throwable) {
// 保持原始 CancellationException 语义;提交失败不会伪装成业务成功。
}
}
}
}

View File

@ -30,6 +30,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.rainy.token.data.repository.CommandCodeUsageRepository
@ -38,6 +39,7 @@ import com.rainy.token.ui.components.rememberWindowSizeClass
import com.rainy.token.ui.components.DebugLogScreen
import com.rainy.token.ui.components.TipsScreen
import com.rainy.token.ui.dashboard.DashboardScreen
import com.rainy.token.ui.dashboard.DashboardViewModel
import com.rainy.token.ui.dashboard.UsageChartViewModel
import com.rainy.token.ui.dashboard.UsageDataScreen
import com.rainy.token.ui.dashboard.UsageDataViewModel
@ -309,6 +311,17 @@ private fun CompactNavHost() {
@Composable
private fun ExpandedLayout() {
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
val dashboardVm: DashboardViewModel = hiltViewModel()
// 宽屏布局下 DashboardScreen 永久挂载,不会收到 ON_RESUME。
// 当详情面板关闭时手动触发 Dashboard 刷新凭据状态。
var prevPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
LaunchedEffect(detailPane) {
if (prevPane != DetailPane.Empty && detailPane == DetailPane.Empty) {
dashboardVm.reloadLocalState()
}
prevPane = detailPane
}
Row(modifier = Modifier.fillMaxSize()) {
Box(
@ -320,7 +333,8 @@ private fun ExpandedLayout() {
onOpenSettings = { detailPane = DetailPane.Settings },
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
viewModel = dashboardVm
)
}
@ -337,7 +351,8 @@ private fun ExpandedLayout() {
) {
ExpandedDetailPane(
pane = detailPane,
onClose = { detailPane = DetailPane.Empty }
onClose = { detailPane = DetailPane.Empty },
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
)
}
}
@ -346,7 +361,8 @@ private fun ExpandedLayout() {
@Composable
private fun ExpandedDetailPane(
pane: DetailPane,
onClose: () -> Unit
onClose: () -> Unit,
onCredentialEditClosed: () -> Unit = {}
) {
when (pane) {
is DetailPane.Empty -> {
@ -426,6 +442,17 @@ private fun ExpandedDetailPane(
}
is DetailPane.Settings -> {
val settingsNavController = rememberNavController()
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时
// 通知 Dashboard 刷新凭据状态(宽屏布局 Dashboard 永久挂载不产生 ON_RESUME)
val routeStack by settingsNavController.currentBackStackEntryAsState()
val currentRoute = routeStack?.destination?.route
var prevRoute by remember { mutableStateOf("settings_main") }
LaunchedEffect(currentRoute) {
if (prevRoute != currentRoute && currentRoute == "settings_main") {
onCredentialEditClosed()
}
prevRoute = currentRoute ?: "settings_main"
}
NavHost(
navController = settingsNavController,
startDestination = "settings_main",

View File

@ -74,6 +74,8 @@ import androidx.compose.ui.zIndex
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
@ -118,6 +120,11 @@ fun DashboardScreen(
onOpenCcgoUsageDetail: () -> Unit = {},
viewModel: DashboardViewModel = hiltViewModel()
) {
// 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求)
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.reloadLocalState()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var showAddWidgetConfirm by remember { mutableStateOf(false) }

View File

@ -3,10 +3,9 @@ package com.rainy.token.ui.dashboard
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
@ -14,6 +13,7 @@ import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -23,20 +23,18 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject
/**
* 仪表盘 ViewModel。
*
* 状态聚合:凭据状态 + 余额缓存 + 在线刷新
* - 启动时读缓存展示(无网时也能看)
* - 启动时读缓存展示(无网时也能看旧数据)
* - refresh() 并行拉取所有服务的最新余额(任一失败不影响其他)
* - 下拉刷新触发同一 refresh()
*/
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache,
private val refreshBalanceUseCase: RefreshBalanceUseCase,
@param:ApplicationContext private val appContext: Context
) : ViewModel() {
@ -44,7 +42,7 @@ class DashboardViewModel @Inject constructor(
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
/** 防止并发 refresh() 调用交错覆盖 UI */
/** 防止并发 refresh() 调用交错覆盖 UI。 */
private val refreshMutex = Mutex()
init {
@ -56,16 +54,57 @@ class DashboardViewModel @Inject constructor(
}
}
/**
* 重新读取本地凭据状态 + 余额缓存,不发起网络请求。
*
* 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片:
* - 凭据变化时采用已清理过的当前账户缓存;
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
* - 不修改 refreshing,旧缓存快照也不能回滚新数据。
*/
fun reloadLocalState() {
viewModelScope.launch {
val localStates = credentialRepository.readLocalStates()
_uiState.update { state ->
state.copy(
cards = state.cards.map { card ->
val local = localStates.getValue(card.service)
val credentialChanged = local.fingerprint != card.credentialFingerprint
val cacheAdvanced = !credentialChanged && isNewer(
candidate = local.cachedBalance,
current = card.cachedBalance
)
card.copy(
credentialState = local.status.state,
credentialFingerprint = local.fingerprint,
cachedBalance = if (credentialChanged) {
local.cachedBalance
} else {
newerOf(card.cachedBalance, local.cachedBalance)
},
// 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。
lastFetchError = if (credentialChanged || cacheAdvanced) {
null
} else {
card.lastFetchError
}
)
}
)
}
}
}
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
private suspend fun loadFromCache() {
val cached = balanceCache.getAll()
val localStates = credentialRepository.readLocalStates()
val cards = ServiceType.entries.map { type ->
buildCard(type, cachedBalance = cached[type], lastFetchError = null)
buildCard(localStates.getValue(type), lastFetchError = null)
}
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
}
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上 */
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上。 */
fun refresh() {
viewModelScope.launch {
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
@ -77,18 +116,21 @@ class DashboardViewModel @Inject constructor(
async {
val status = credentialRepository.statusFor(type)
if (status.state == CredentialStatus.State.NOT_CONFIGURED) {
type to null // 未配置的服务不拉
type to null // 未配置的服务不拉
} else {
type to refreshBalanceUseCase(type)
}
}
}.awaitAll().toMap()
}
val newCache = balanceCache.getAll()
val localStates = credentialRepository.readLocalStates()
val cards = ServiceType.entries.map { type ->
val result = results[type]
val errMsg = result?.exceptionOrNull()?.message
buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg)
val error = result?.exceptionOrNull()
val errMsg = error
?.takeUnless { it is RepositoryError.CredentialChanged }
?.message
buildCard(localStates.getValue(type), lastFetchError = errMsg)
}
_uiState.update { it.copy(refreshing = false, cards = cards) }
// 刷新成功后更新桌面小组件
@ -101,19 +143,31 @@ class DashboardViewModel @Inject constructor(
}
}
private suspend fun buildCard(
type: ServiceType,
cachedBalance: CachedBalance?,
private fun buildCard(
local: CredentialRepository.LocalState,
lastFetchError: String?
): DashboardCardUi {
val status = credentialRepository.statusFor(type)
return DashboardCardUi(
service = type,
credentialState = status.state,
cachedBalance = cachedBalance,
lastFetchError = lastFetchError
)
): DashboardCardUi = DashboardCardUi(
service = local.status.service,
credentialState = local.status.state,
credentialFingerprint = local.fingerprint,
cachedBalance = local.cachedBalance,
lastFetchError = lastFetchError
)
private fun newerOf(
current: CachedBalance?,
candidate: CachedBalance?
): CachedBalance? = when {
current == null -> candidate
candidate == null -> current
candidate.fetchedAt > current.fetchedAt -> candidate
else -> current
}
private fun isNewer(
candidate: CachedBalance?,
current: CachedBalance?
): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt)
}
data class DashboardUiState(
@ -125,17 +179,18 @@ data class DashboardUiState(
data class DashboardCardUi(
val service: ServiceType,
val credentialState: CredentialStatus.State,
val credentialFingerprint: String?,
val cachedBalance: CachedBalance?,
val lastFetchError: String?
) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示) */
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
/** 卡片顶部状态徽章 */
/** 卡片顶部状态徽章。 */
val statusBadge: String get() = when {
credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置"
lastFetchError != null -> "刷新失败"
cachedBalance == null -> "未获取"
else -> "正常"
}
}
}

View File

@ -55,6 +55,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.FetchMethod
@ -94,6 +96,10 @@ fun ServiceDetailScreen(
viewModel: ServiceDetailViewModel = hiltViewModel()
) {
LaunchedEffect(service) { viewModel.bind(service) }
// 从凭据编辑页返回时重新读取凭据状态 + 缓存
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.reloadCredentialState()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val triggerState by viewModel.triggerState.collectAsStateWithLifecycle()
val models by viewModel.models.collectAsStateWithLifecycle()

View File

@ -8,23 +8,20 @@ import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TriggerError
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 服务详情页 ViewModel。计划 3.3:UiState = Loading | Fresh | Stale | Error
* 阶段 3 实现:DeepSeek 的真实刷新。其他服务调用 RefreshBalanceUseCase 会得到
* UnsupportedServiceException,进入"暂未支持"提示状态。
*/
/** 服务详情页 ViewModel。 */
@HiltViewModel
class ServiceDetailViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
@ -36,28 +33,49 @@ class ServiceDetailViewModel @Inject constructor(
private val _uiState = MutableStateFlow(ServiceDetailUiState())
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
/** Codex 一键激活用量状态 */
private val _triggerState = MutableStateFlow<TriggerState>(TriggerState.Idle)
val triggerState: StateFlow<TriggerState> = _triggerState.asStateFlow()
/** 可用模型列表(Codex / OCGO / Ollama 共用) */
private val _models = MutableStateFlow<List<String>>(emptyList())
val models: StateFlow<List<String>> = _models.asStateFlow()
/** 用户选中的模型(持久化) */
private val _selectedModel = MutableStateFlow<String?>(null)
val selectedModel: StateFlow<String?> = _selectedModel.asStateFlow()
/** 模型列表加载状态 */
private val _modelsLoading = MutableStateFlow(false)
val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
/** 当前凭据的不可逆 SHA-256 指纹。 */
private var currentCredentialFingerprint: String? = null
/** 每次发起刷新 +1;凭据变化时旧刷新结果会被丢弃。 */
private var refreshGeneration: Int = 0
/** 每次切换服务 +1;防止宽屏快速切换时旧服务协程覆盖新服务 UI。 */
private var serviceGeneration: Int = 0
companion object {
private const val CODEX_PREFS = "codex_trigger_prefs"
private const val KEY_SELECTED_MODEL = "selected_model"
private const val KEY_MODELS_CACHE = "models_cache"
private const val OCGO_PREFS = "ocgo_trigger_prefs"
private const val OLLAMA_PREFS = "ollama_trigger_prefs"
internal fun credentialFingerprint(credential: Credential?): String? =
CredentialRepository.credentialFingerprint(credential)
enum class CredentialChange { UNCHANGED, NEW, REPLACED, DELETED, NONE_TO_NONE }
internal fun classifyCredentialChange(
oldFingerprint: String?,
newFingerprint: String?
): CredentialChange = when {
oldFingerprint == null && newFingerprint == null -> CredentialChange.NONE_TO_NONE
oldFingerprint == null && newFingerprint != null -> CredentialChange.NEW
oldFingerprint != null && newFingerprint == null -> CredentialChange.DELETED
oldFingerprint == newFingerprint -> CredentialChange.UNCHANGED
else -> CredentialChange.REPLACED
}
}
private fun prefsNameFor(service: ServiceType): String = when (service) {
@ -69,28 +87,95 @@ class ServiceDetailViewModel @Inject constructor(
fun bind(service: ServiceType) {
if (_serviceType.value == service) return
serviceGeneration++
refreshGeneration++
_serviceType.value = service
// 恢复持久化状态
currentCredentialFingerprint = null
_models.value = emptyList()
_selectedModel.value = null
_modelsLoading.value = false
_triggerState.value = TriggerState.Idle
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
val savedModel = loadSelectedModel(service)
if (savedModel != null) _selectedModel.value = savedModel
val cachedModels = loadModelsCache(service)
if (cachedModels.isNotEmpty()) _models.value = cachedModels
loadSelectedModel(service)?.let { _selectedModel.value = it }
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
}
loadFromCache()
}
/**
* 返回页面时同步凭据和缓存。凭据未变时保留错误/Loading,但按 fetchedAt 采用
* Widget 或其他入口写入的更新缓存;凭据新增或替换时废弃旧状态并重新刷新。
*/
fun reloadCredentialState() {
val type = _serviceType.value ?: return
val serviceGen = serviceGeneration
viewModelScope.launch {
val local = credentialRepository.readLocalState(type)
val newFingerprint = local.fingerprint
val change = classifyCredentialChange(currentCredentialFingerprint, newFingerprint)
val newHasCredential = local.status.state != CredentialStatus.State.NOT_CONFIGURED
if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch
currentCredentialFingerprint = newFingerprint
when (change) {
CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> {
_uiState.update { current ->
mergeUnchangedLocalState(
current = current,
localCache = local.cachedBalance,
hasCredential = newHasCredential
)
}
}
CredentialChange.NEW, CredentialChange.REPLACED -> {
_uiState.update {
it.copy(
hasCredential = true,
cached = local.cachedBalance,
state = State.Loading
)
}
refresh()
}
CredentialChange.DELETED -> {
refreshGeneration++
_uiState.update {
it.copy(
hasCredential = false,
cached = local.cachedBalance,
state = State.Error(
local.cachedBalance?.balance,
"凭据未配置",
RepositoryError.InvalidCredential()
)
)
}
}
}
}
}
fun refresh() {
val type = _serviceType.value ?: return
val serviceGen = serviceGeneration
val gen = ++refreshGeneration
viewModelScope.launch {
val requestFingerprint = credentialRepository.readLocalState(type).fingerprint
if (isStaleRequest(type, serviceGen, gen)) return@launch
_uiState.update { it.copy(state = State.Loading) }
// 手动输入模式:直接展示用户上次输入的余额(如果有)
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
if (config.method == com.rainy.token.domain.service.FetchMethod.MANUAL) {
val cached = balanceCache.get(type)
if (isStaleRequest(type, serviceGen, gen)) return@launch
_uiState.update {
it.copy(
cached = newerOf(it.cached, cached),
state = if (cached != null) {
State.Fresh(cached.balance)
} else {
@ -102,14 +187,60 @@ class ServiceDetailViewModel @Inject constructor(
}
val result = refreshBalanceUseCase(type)
if (isStaleRequest(type, serviceGen, gen)) return@launch
val local = credentialRepository.readLocalState(type)
if (isStaleRequest(type, serviceGen, gen)) return@launch
val fingerprintChanged = local.fingerprint != requestFingerprint
result
.onSuccess { balance -> _uiState.update { it.copy(state = State.Fresh(balance)) } }
.onFailure { error ->
val cached = balanceCache.get(type)
.onSuccess { balance ->
// 认证字段可能因 Codex Token 轮换而变化,也可能是用户在请求完成后
// 切换了账户。两种情况下只信任当前凭据对应的持久化缓存;绝不把
// 请求结果中的旧账户余额重新挂到新凭据上。
val currentCache = if (fingerprintChanged) {
local.cachedBalance
} else {
newerOf(_uiState.value.cached, local.cachedBalance)
}
currentCredentialFingerprint = local.fingerprint
if (fingerprintChanged && currentCache == null) {
loadFromCache()
return@onSuccess
}
_uiState.update {
val mergedCache = if (fingerprintChanged) {
local.cachedBalance
} else {
newerOf(it.cached, local.cachedBalance)
}
it.copy(
hasCredential = true,
cached = mergedCache,
state = State.Fresh(mergedCache?.balance ?: balance)
)
}
}
.onFailure { error ->
if (error is RepositoryError.CredentialChanged) {
loadFromCache()
return@onFailure
}
// 指纹变化时不复用当前页面里的旧账户缓存,也不提前接受新指纹;
// 返回页面的 reload 会把它识别为 REPLACED 并按新凭据重验。
if (!fingerprintChanged) {
currentCredentialFingerprint = local.fingerprint
}
_uiState.update { current ->
val mergedCache = if (fingerprintChanged) {
local.cachedBalance
} else {
newerOf(current.cached, local.cachedBalance)
}
current.copy(
cached = mergedCache,
state = State.Error(
cached = cached?.balance,
cached = mergedCache?.balance,
message = errorMessage(error),
error = error
)
@ -119,12 +250,10 @@ class ServiceDetailViewModel @Inject constructor(
}
}
/**
* 加载可用模型列表(优先从缓存读取,首次才请求网络)。
* @param force true 时强制从网络刷新
*/
/** 加载可用模型列表(优先缓存,force=true 时强制联网)。 */
fun loadModels(force: Boolean = false) {
val service = _serviceType.value ?: return
val serviceGen = serviceGeneration
if (!force && _models.value.isNotEmpty()) return
viewModelScope.launch {
_modelsLoading.value = true
@ -132,8 +261,12 @@ class ServiceDetailViewModel @Inject constructor(
ServiceType.CODEX -> refreshBalanceUseCase.fetchCodexModels()
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.fetchOpenCodeGoModels()
ServiceType.OLLAMA -> refreshBalanceUseCase.fetchOllamaModels()
else -> Result.failure(RepositoryError.Unknown(IllegalArgumentException("不支持模型列表")))
else -> Result.failure(
RepositoryError.Unknown(IllegalArgumentException("不支持模型列表"))
)
}
if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch
result
.onSuccess { list ->
_models.value = list
@ -160,18 +293,15 @@ class ServiceDetailViewModel @Inject constructor(
}
}
/** 用户选择模型(持久化) */
fun selectModel(model: String) {
_selectedModel.value = model
_serviceType.value?.let { persistSelectedModel(it, model) }
}
/**
* 一键激活用量:发送 API 请求触发用量统计。
* 支持 Codex / OpenCode Go / Ollama 三个服务。
*/
/** 一键激活用量。 */
fun triggerUsage() {
val service = _serviceType.value ?: return
val serviceGen = serviceGeneration
val model = _selectedModel.value ?: run {
_triggerState.value = TriggerState.Error("请先选择模型", null)
return
@ -182,61 +312,79 @@ class ServiceDetailViewModel @Inject constructor(
ServiceType.CODEX -> refreshBalanceUseCase.triggerCodexUsage(model)
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.triggerOpenCodeGoUsage(model)
ServiceType.OLLAMA -> refreshBalanceUseCase.triggerOllamaUsage(model)
else -> Result.failure(RepositoryError.Unknown(IllegalArgumentException("不支持激活用量")))
else -> Result.failure(
RepositoryError.Unknown(IllegalArgumentException("不支持激活用量"))
)
}
if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch
result
.onSuccess { responseBody ->
_triggerState.value = TriggerState.Success(responseBody)
// 等待 2 秒让服务端处理用量,再刷新余额
kotlinx.coroutines.delay(2000)
refresh()
if (serviceGen == serviceGeneration && _serviceType.value == service) {
refresh()
}
}
.onFailure { error ->
val msg: String
val respBody: String?
val message: String
val responseBody: String?
when (error) {
is TriggerError -> {
msg = error.summary
respBody = error.responseBody.ifBlank { null }
message = error.summary
responseBody = error.responseBody.ifBlank { null }
}
is RepositoryError.InvalidCredential -> {
msg = error.message ?: "凭据无效,请重新登录"
respBody = null
message = error.message ?: "凭据无效,请重新登录"
responseBody = null
}
is RepositoryError.CredentialChanged -> {
message = "凭据已变更,请重新操作"
responseBody = null
}
is RepositoryError.Network -> {
msg = "网络异常"
respBody = null
message = "网络异常"
responseBody = null
}
else -> {
msg = error.message ?: "未知错误"
respBody = null
message = error.message ?: "未知错误"
responseBody = null
}
}
_triggerState.value = TriggerState.Error(msg, respBody)
_triggerState.value = TriggerState.Error(message, responseBody)
}
}
}
/** 关闭响应弹窗,回到 Idle */
fun dismissTrigger() {
_triggerState.value = TriggerState.Idle
}
/**
* 手动输入模式:保存用户填的余额值。
*/
fun saveManualBalance(amount: Double) {
val type = _serviceType.value ?: return
val serviceGen = serviceGeneration
viewModelScope.launch {
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
val balance = com.rainy.token.domain.model.ServiceBalance(
val balance = ServiceBalance(
service = type,
amount = amount,
unit = config.displayUnit,
isAvailable = true
)
balanceCache.put(type, balance)
_uiState.update { it.copy(state = State.Fresh(balance)) }
val cached = balanceCache.get(type)
if (serviceGen == serviceGeneration && _serviceType.value == type) {
_uiState.update {
it.copy(
cached = newerOf(it.cached, cached),
state = State.Fresh(balance)
)
}
}
}
}
@ -245,11 +393,11 @@ class ServiceDetailViewModel @Inject constructor(
viewModelScope.launch {
val credential = credentialRepository.get(type) ?: return@launch
val updated = when (credential) {
is com.rainy.token.domain.model.Credential.ApiKeyCredential ->
is Credential.ApiKeyCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
is com.rainy.token.domain.model.Credential.SessionCredential ->
is Credential.SessionCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
is com.rainy.token.domain.model.Credential.CodexCredential ->
is Credential.CodexCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis())
}
credentialRepository.save(updated)
@ -259,18 +407,31 @@ class ServiceDetailViewModel @Inject constructor(
private fun loadFromCache() {
val type = _serviceType.value ?: return
val serviceGen = serviceGeneration
viewModelScope.launch {
val status = credentialRepository.statusFor(type)
val cached = balanceCache.get(type)
val local = credentialRepository.readLocalState(type)
val status = local.status
val cached = local.cachedBalance
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
val isManual = config.method == com.rainy.token.domain.service.FetchMethod.MANUAL
val newState: State = when {
isManual && cached != null -> State.Stale(cached.balance, cached.fetchedAt)
isManual -> State.ManualModeHint
cached != null && status.state == CredentialStatus.State.OK -> State.Stale(cached.balance, cached.fetchedAt)
cached != null -> State.Error(cached.balance, "凭据未配置或已过期", RepositoryError.InvalidCredential())
status.state == CredentialStatus.State.NOT_CONFIGURED ->
State.Error(null, "凭据未配置", RepositoryError.InvalidCredential())
cached != null && status.state == CredentialStatus.State.OK ->
State.Stale(cached.balance, cached.fetchedAt)
cached != null ->
State.Error(
cached.balance,
"凭据未配置或已过期",
RepositoryError.InvalidCredential()
)
else -> State.Loading
}
if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch
currentCredentialFingerprint = local.fingerprint
_uiState.update {
it.copy(
hasCredential = status.state != CredentialStatus.State.NOT_CONFIGURED,
@ -284,37 +445,94 @@ class ServiceDetailViewModel @Inject constructor(
}
}
private fun mergeUnchangedLocalState(
current: ServiceDetailUiState,
localCache: CachedBalance?,
hasCredential: Boolean
): ServiceDetailUiState {
val cacheAdvanced = isNewer(candidate = localCache, current = current.cached)
val mergedCache = newerOf(current.cached, localCache)
val mergedState: State = when {
!hasCredential -> State.Error(
mergedCache?.balance,
"凭据未配置",
RepositoryError.InvalidCredential()
)
current.state is State.Loading -> current.state
current.state is State.Error -> current.state.copy(
cached = mergedCache?.balance ?: current.state.cached
)
cacheAdvanced && current.state is State.Fresh ->
State.Fresh(mergedCache!!.balance)
cacheAdvanced && current.state is State.Stale ->
State.Stale(mergedCache!!.balance, mergedCache.fetchedAt)
current.state is State.Fresh -> current.state
current.state is State.Stale -> current.state
mergedCache != null -> State.Stale(mergedCache.balance, mergedCache.fetchedAt)
else -> current.state
}
return current.copy(
hasCredential = hasCredential,
cached = mergedCache,
state = mergedState
)
}
private fun newerOf(
current: CachedBalance?,
candidate: CachedBalance?
): CachedBalance? = when {
current == null -> candidate
candidate == null -> current
candidate.fetchedAt > current.fetchedAt -> candidate
else -> current
}
private fun isNewer(
candidate: CachedBalance?,
current: CachedBalance?
): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt)
private fun isStaleRequest(type: ServiceType, serviceGen: Int, refreshGen: Int): Boolean =
refreshGen != refreshGeneration ||
serviceGen != serviceGeneration ||
_serviceType.value != type
private fun errorMessage(error: Throwable): String = when (error) {
is RepositoryError.InvalidCredential -> "凭据无效,请在设置中重新配置"
is RepositoryError.RateLimited -> "请求过于频繁${error.retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}"
is RepositoryError.CredentialChanged -> "凭据已变更,正在重新加载"
is RepositoryError.RateLimited ->
"请求过于频繁${error.retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}"
is RepositoryError.Network -> "网络异常,请检查网络"
is RepositoryError.ServerError -> "服务端异常 (HTTP ${error.code})"
is RepositoryError.ParseError -> "数据解析失败: ${error.message}"
else -> error.message ?: "未知错误"
}
// ── 模型持久化(Codex / OCGO / Ollama 各自独立 prefs) ──
private fun prefs(service: ServiceType): android.content.SharedPreferences =
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(prefsNameFor(service), android.content.Context.MODE_PRIVATE)
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(
prefsNameFor(service),
android.content.Context.MODE_PRIVATE
)
private fun persistSelectedModel(service: ServiceType, model: String?) {
prefs(service).edit().putString(KEY_SELECTED_MODEL, model).apply()
}
private fun loadSelectedModel(service: ServiceType): String? = prefs(service).getString(KEY_SELECTED_MODEL, null)
private fun loadSelectedModel(service: ServiceType): String? =
prefs(service).getString(KEY_SELECTED_MODEL, null)
private fun persistModelsCache(service: ServiceType, models: List<String>) {
prefs(service).edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply()
}
private fun loadModelsCache(service: ServiceType): List<String> =
prefs(service).getString(KEY_MODELS_CACHE, "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList()
prefs(service).getString(KEY_MODELS_CACHE, "")
?.split("\n")
?.filter { it.isNotBlank() }
?: emptyList()
}
/**
* 计划 7.1 规定的 UI 状态。
*/
sealed class State {
data object Loading : State()
data class Fresh(val data: ServiceBalance) : State()
@ -324,7 +542,6 @@ sealed class State {
val message: String,
val error: Throwable
) : State()
/** 手动输入模式:尚未填入任何余额 */
data object ManualModeHint : State()
}
@ -334,10 +551,9 @@ data class ServiceDetailUiState(
val cached: CachedBalance? = null
)
/** Codex 一键激活用量状态 */
sealed class TriggerState {
data object Idle : TriggerState()
data object Loading : TriggerState()
data class Success(val responseBody: String) : TriggerState()
data class Error(val message: String, val responseBody: String?) : TriggerState()
}
}

View File

@ -1,14 +1,10 @@
package com.rainy.token.ui.settings
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.repository.CommandCodeGoRepository
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.domain.model.CookieEntry
import com.rainy.token.domain.model.Credential
@ -17,6 +13,8 @@ import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -25,8 +23,6 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
import javax.inject.Provider
/**
* 凭据编辑页 ViewModel。
@ -38,10 +34,7 @@ import javax.inject.Provider
@HiltViewModel
class CredentialEditViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
private val ollamaRepositoryProvider: Provider<OllamaRepository>
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>
) : ViewModel() {
private val _uiState = MutableStateFlow(CredentialEditUiState())
@ -135,7 +128,6 @@ class CredentialEditViewModel @Inject constructor(
lastVerifiedAt = 0L
)).copy(key = trimmedKey)
credentialRepository.save(updated)
// 同步回写 UI 状态(去掉前后空白后的版本),避免下次进来还看到带空格的旧值
_uiState.update {
it.copy(
apiKey = trimmedKey,
@ -146,18 +138,7 @@ class CredentialEditViewModel @Inject constructor(
}
}
/**
* 保存 API Key 后立即测试连通性(计划 2.3 凭据有效性校验)。
* 复用 RefreshBalanceUseCase,成功/失败都把详细信息写进 message。
*/
/**
* 通用模板:保存凭据 → 测试连接 → 失败按需回滚。
*
* @param type 服务类型
* @param saveAndPrep 保存新凭据,返回 (旧凭据用于回滚, 测试 suspend block)
* @param formatSuccess 成功消息格式化
* @param rollbackOnFailure 失败时是否回滚到旧凭据
*/
/** 通用模板:保存凭据 → 测试连接 → 失败按需回滚。 */
private suspend fun testAndRollback(
type: ServiceType,
saveAndPrep: suspend () -> Pair<Credential?, suspend () -> Result<com.rainy.token.domain.model.ServiceBalance>>,
@ -165,24 +146,37 @@ class CredentialEditViewModel @Inject constructor(
rollbackOnFailure: Boolean
) {
val (previous, testBlock) = saveAndPrep()
val testedSnapshot = credentialRepository.snapshot(type)
val result = testBlock()
if (result.isSuccess) {
val bal = result.getOrNull()
_uiState.update { it.copy(message = formatSuccess(bal!!), hasExisting = true) }
} else {
if (rollbackOnFailure) {
if (previous != null) credentialRepository.save(previous) else credentialRepository.remove(type)
val rolledBack = if (rollbackOnFailure && testedSnapshot != null) {
credentialRepository.restoreIfCurrent(testedSnapshot, previous)
} else {
false
}
val err = result.exceptionOrNull()
val reason = when (err) {
is RepositoryError.InvalidCredential -> "服务拒绝该凭据 (401/403)"
is RepositoryError.CredentialChanged -> "测试期间凭据已变更"
is RepositoryError.RateLimited -> "请求过于频繁 (429)"
is RepositoryError.ServerError -> "服务端错误 (${err.code})"
is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}"
else -> err?.message ?: "未知错误"
}
val hasExisting = credentialRepository.get(type) != null
val rollbackNote = when {
!rollbackOnFailure -> ""
rolledBack -> ",已恢复原凭据"
else -> ",检测到凭据已变化,未执行回滚"
}
_uiState.update {
it.copy(message = "测试失败。$reason", hasExisting = if (rollbackOnFailure) previous != null else true)
it.copy(
message = "测试失败。$reason$rollbackNote",
hasExisting = hasExisting
)
}
}
}
@ -202,7 +196,9 @@ class CredentialEditViewModel @Inject constructor(
viewModelScope.launch {
val existing = credentialRepository.get(type) as? Credential.ApiKeyCredential
val updated = (existing ?: Credential.ApiKeyCredential(
service = type, key = trimmedKey, lastVerifiedAt = 0L
service = type,
key = trimmedKey,
lastVerifiedAt = 0L
)).copy(key = trimmedKey)
credentialRepository.save(updated)
_uiState.update { it.copy(apiKey = trimmedKey) }
@ -215,7 +211,7 @@ class CredentialEditViewModel @Inject constructor(
}
}
/** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替 */
/** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */
private fun maskedKeyPreview(key: String): String {
if (key.length <= 8) return "*** (长度 ${key.length}) ***"
val head = key.take(4)
@ -223,9 +219,6 @@ class CredentialEditViewModel @Inject constructor(
return "$head***$tail (长度 ${key.length})"
}
/**
* 保存 OpenCode Go 的 cookie + workspaceId。
*/
fun saveOpenCodeGoSession() {
val type = serviceType ?: return
val current = _uiState.value
@ -239,9 +232,6 @@ class CredentialEditViewModel @Inject constructor(
}
}
/**
* 保存并立即测试连接。测试失败则回滚凭据。
*/
fun testAndSaveOpenCodeGo() {
val type = serviceType ?: return
val current = _uiState.value
@ -254,7 +244,7 @@ class CredentialEditViewModel @Inject constructor(
doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim())
testAndRollback(
type = type,
saveAndPrep = { previous to { openCodeGoRepositoryProvider.get().fetchBalance() } },
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
formatSuccess = { "连接成功,凭据已保存" },
rollbackOnFailure = true
)
@ -298,7 +288,11 @@ class CredentialEditViewModel @Inject constructor(
)
credentialRepository.save(updated)
_uiState.update {
it.copy(message = "已保存 ${cookies.size} 个 Cookie", hasExisting = true, cookieCount = cookies.size)
it.copy(
message = "已保存 ${cookies.size} 个 Cookie",
hasExisting = true,
cookieCount = cookies.size
)
}
}
}
@ -307,12 +301,6 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update { it.copy(codexAuthJson = value) }
}
/**
* 保存 Codex auth.json 完整内容,解析并存储为 CodexCredential。
* 支持两种格式:
* 1. 完整 {"tokens": {"access_token": "...", "refresh_token": "...", ...}}
* 2. 扁平 {"access_token": "...", "refresh_token": "...", ...}
*/
fun saveCodexAuthJson() {
val type = serviceType ?: return
val current = _uiState.value
@ -328,13 +316,11 @@ class CredentialEditViewModel @Inject constructor(
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content
val accountId = tokens["account_id"]?.jsonPrimitive?.content ?: ""
// 支持三种过期时间格式:
// expiresAt / expires_at → epoch 毫秒(绝对时间)
// expires_in → 相对秒数(token 有效期),转为 now + seconds*1000
// 无该字段 → 默认 10 天后过期(假定 token 尚未到期)
val expiresAt = tokens["expiresAt"]?.jsonPrimitive?.content?.toLongOrNull()
?: tokens["expires_at"]?.jsonPrimitive?.content?.toLongOrNull()
?: tokens["expires_in"]?.jsonPrimitive?.content?.toLongOrNull()?.let { System.currentTimeMillis() + it * 1000L }
?: tokens["expires_in"]?.jsonPrimitive?.content?.toLongOrNull()?.let {
System.currentTimeMillis() + it * 1000L
}
?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
@ -351,17 +337,18 @@ class CredentialEditViewModel @Inject constructor(
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(newCred)
_uiState.update { it.copy(message = "已保存 Codex 凭据,token 到期后会自动刷新", hasExisting = true) }
_uiState.update {
it.copy(
message = "已保存 Codex 凭据,token 到期后会自动刷新",
hasExisting = true
)
}
} catch (e: Exception) {
_uiState.update { it.copy(message = "解析失败:${e.message ?: "JSON 格式错误"}") }
}
}
}
/**
* 保存 Codex auth.json 并立即测试连接。
* API Key 存 token 字段,cookie 字符串解析后存 cookies 列表。
*/
fun saveCommandCodeGoCredential() {
val type = serviceType ?: return
val current = _uiState.value
@ -372,7 +359,9 @@ class CredentialEditViewModel @Inject constructor(
}
val cookies = if (current.cookieInput.isNotBlank()) {
parseCookieString(current.cookieInput)
} else emptyList()
} else {
emptyList()
}
viewModelScope.launch {
val existing = credentialRepository.get(type) as? Credential.SessionCredential
@ -397,9 +386,6 @@ class CredentialEditViewModel @Inject constructor(
}
}
/**
* 保存并测试 CommandCode Go 连通性。
*/
fun testAndSaveCommandCodeGo() {
val type = serviceType ?: return
val current = _uiState.value
@ -411,17 +397,27 @@ class CredentialEditViewModel @Inject constructor(
viewModelScope.launch {
val cookies = if (current.cookieInput.isNotBlank()) {
parseCookieString(current.cookieInput)
} else emptyList()
} else {
emptyList()
}
val existing = credentialRepository.get(type) as? Credential.SessionCredential
val updated = (existing ?: Credential.SessionCredential(
service = type, cookies = cookies, token = trimmedKey
)).copy(cookies = cookies, token = trimmedKey, lastVerifiedAt = System.currentTimeMillis())
service = type,
cookies = cookies,
token = trimmedKey
)).copy(
cookies = cookies,
token = trimmedKey,
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(updated)
_uiState.update { it.copy(apiKey = trimmedKey) }
testAndRollback(
type = type,
saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(type) } },
formatSuccess = { bal -> "连接成功!余额: \$${String.format(java.util.Locale.US, "%.2f", bal.amount)}" },
formatSuccess = { bal ->
"连接成功!余额: \$${String.format(java.util.Locale.US, "%.2f", bal.amount)}"
},
rollbackOnFailure = false
)
}
@ -435,10 +431,6 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update { it.copy(triggerApiKey = value) }
}
/**
* 保存触发用量 API Key(OCGO / Ollama 共用)。
* 合并保存到已有的 SessionCredential 中。
*/
fun saveTriggerApiKey() {
val type = serviceType ?: return
val current = _uiState.value
@ -463,9 +455,6 @@ class CredentialEditViewModel @Inject constructor(
}
}
/**
* 保存 Ollama Pro 的 Cookie 字符串。
*/
fun saveOllamaCredential() {
val type = serviceType ?: return
val current = _uiState.value
@ -479,9 +468,6 @@ class CredentialEditViewModel @Inject constructor(
}
}
/**
* 保存并立即测试 Ollama Cloud 连接。
*/
fun testAndSaveOllama() {
val type = serviceType ?: return
val current = _uiState.value
@ -494,8 +480,10 @@ class CredentialEditViewModel @Inject constructor(
doSaveOllama(current.ollamaCookie.trim())
testAndRollback(
type = type,
saveAndPrep = { previous to { ollamaRepositoryProvider.get().fetchBalance() } },
formatSuccess = { bal -> "连接成功!Session: ${bal.amount}% · ${bal.extras["plan"] ?: "—"}" },
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
formatSuccess = { bal ->
"连接成功!Session: ${bal.amount}% · ${bal.extras["plan"] ?: "—"}"
},
rollbackOnFailure = true
)
}
@ -554,10 +542,14 @@ class CredentialEditViewModel @Inject constructor(
)
}
when {
ws != null && auth != null -> _uiState.update { it.copy(message = "已识别 workspaceId 和 auth cookie") }
ws != null -> _uiState.update { it.copy(message = "已识别 workspaceId,请再粘贴 auth cookie") }
auth != null -> _uiState.update { it.copy(message = "已识别 auth cookie,请再粘贴 workspaceId") }
else -> _uiState.update { it.copy(message = "未识别到有效凭据") }
ws != null && auth != null ->
_uiState.update { it.copy(message = "已识别 workspaceId 和 auth cookie") }
ws != null ->
_uiState.update { it.copy(message = "已识别 workspaceId,请再粘贴 auth cookie") }
auth != null ->
_uiState.update { it.copy(message = "已识别 auth cookie,请再粘贴 workspaceId") }
else ->
_uiState.update { it.copy(message = "未识别到有效凭据") }
}
}
@ -580,8 +572,9 @@ class CredentialEditViewModel @Inject constructor(
return cookieString.split(";")
.mapNotNull { entry ->
val parts = entry.trim().split("=", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) return@mapNotNull null
// cookie 值原样存储,不做任何解码(better-auth 签名包含了原始字符)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
return@mapNotNull null
}
CookieEntry(name = parts[0].trim(), value = parts[1].trim())
}
}
@ -602,4 +595,4 @@ data class CredentialEditUiState(
/** OCGO / Ollama 的一键激活用量 API Key */
val triggerApiKey: String = "",
val message: String? = null
)
)

View File

@ -36,6 +36,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
@ -64,6 +66,11 @@ fun SettingsScreen(
onOpenDebugLog: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
// 重新进入设置页时刷新凭据状态(从凭据编辑页保存/删除后返回)
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(

View File

@ -0,0 +1,118 @@
package com.rainy.token.data.repository
import com.rainy.token.domain.model.CredentialStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Unit tests for [CredentialRepository.determineCredentialState].
*
* Tests the credential status logic that determines whether a saved credential
* should be shown as OK, WARNING, or NOT_CONFIGURED in the settings page.
*
* This is a regression test for Issue #2: after saving a credential and returning
* to the settings page, the status was not refreshed. The fix uses LifecycleEventEffect(ON_RESUME)
* to re-trigger [CredentialRepository.statusForAll], which internally calls
* [CredentialRepository.determineCredentialState].
*
* All tests are pure JVM — no Android framework, no network.
*/
class CredentialRepositoryTest {
// ── determineCredentialState ──
@Test
fun `lastVerifiedAt 0 returns WARNING (saved but not yet verified)`() {
// When a credential is just saved (e.g. via saveApiKey), lastVerifiedAt = 0L.
// This should show as WARNING ("需要重新验证"), NOT as NOT_CONFIGURED.
val state = CredentialRepository.determineCredentialState(0L, System.currentTimeMillis())
assertEquals(CredentialStatus.State.WARNING, state)
}
@Test
fun `recently verified returns OK`() {
val now = System.currentTimeMillis()
val state = CredentialRepository.determineCredentialState(now, now)
assertEquals(CredentialStatus.State.OK, state)
}
@Test
fun `verified within 7 days returns OK`() {
val now = System.currentTimeMillis()
val sixDaysAgo = now - 6L * 24 * 3600 * 1000
val state = CredentialRepository.determineCredentialState(sixDaysAgo, now)
assertEquals(CredentialStatus.State.OK, state)
}
@Test
fun `verified exactly 7 days ago returns OK (boundary)`() {
val now = System.currentTimeMillis()
val sevenDays = 7L * 24 * 3600 * 1000
// now - lastVerifiedAt == 7 days exactly → not > 7 days → OK
val state = CredentialRepository.determineCredentialState(now - sevenDays, now)
assertEquals(CredentialStatus.State.OK, state)
}
@Test
fun `verified more than 7 days ago returns WARNING`() {
val now = System.currentTimeMillis()
val eightDaysAgo = now - 8L * 24 * 3600 * 1000
val state = CredentialRepository.determineCredentialState(eightDaysAgo, now)
assertEquals(CredentialStatus.State.WARNING, state)
}
@Test
fun `lastVerifiedAt in future returns OK`() {
// Edge case: if clock skew causes lastVerifiedAt > now, should be OK
val now = System.currentTimeMillis()
val state = CredentialRepository.determineCredentialState(now + 10000L, now)
assertEquals(CredentialStatus.State.OK, state)
}
// ── State transition scenarios (regression for Issue #2) ──
@Test
fun `freshly saved API key credential shows WARNING not NOT_CONFIGURED`() {
// Scenario: User saves an API Key (lastVerifiedAt = 0L), returns to settings page.
// Before fix: settings page still showed "未配置" (NOT_CONFIGURED) because
// SettingsViewModel never re-read credentials.
// After fix: ON_RESUME triggers refresh(), which calls statusFor() →
// credential exists → determineCredentialState(0L, now) → WARNING.
// This test verifies the state logic is correct (WARNING, not NOT_CONFIGURED).
val state = CredentialRepository.determineCredentialState(0L, System.currentTimeMillis())
assertEquals(CredentialStatus.State.WARNING, state)
// NOT_CONFIGURED is only returned by statusFor() when credential == null,
// not by determineCredentialState. So a saved credential will never be
// NOT_CONFIGURED — confirming the fix works once refresh() is triggered.
}
@Test
fun `verified credential shows OK after refresh`() {
// Scenario: User saves credential and tests it (lastVerifiedAt = now),
// returns to settings page. Should show OK.
val now = System.currentTimeMillis()
val state = CredentialRepository.determineCredentialState(now, now)
assertEquals(CredentialStatus.State.OK, state)
}
@Test
fun `deleted credential would show NOT_CONFIGURED via statusFor`() {
// Scenario: User deletes credential, returns to settings page.
// statusFor() returns NOT_CONFIGURED when credential == null (get() returns null).
// determineCredentialState is not called in this path.
// This test documents that behavior — NOT_CONFIGURED comes from statusFor's
// null check, not from determineCredentialState.
// (Cannot test statusFor directly without Android Keystore.)
// Verify that determineCredentialState is never the source of NOT_CONFIGURED:
// it only returns WARNING or OK.
for (lastVerifiedAt in listOf(0L, System.currentTimeMillis(), System.currentTimeMillis() + 10000L)) {
val state = CredentialRepository.determineCredentialState(lastVerifiedAt, System.currentTimeMillis())
assertNotEquals(
"determineCredentialState must never return NOT_CONFIGURED (only statusFor does)",
CredentialStatus.State.NOT_CONFIGURED,
state
)
}
}
}

View File

@ -0,0 +1,185 @@
package com.rainy.token.data.repository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class RefreshWriteSessionTest {
@Test
fun `rotated Codex token is not overwritten by later stale verification copy`() {
val original = Credential.CodexCredential(
service = ServiceType.CODEX,
accessToken = "access-old",
refreshToken = "refresh-old",
accountId = "account-1",
expiresAt = 1000L,
lastVerifiedAt = 10L
)
val snapshot = CredentialRepository.CredentialSnapshot(
credential = original,
revision = 7L,
fingerprint = CredentialRepository.credentialFingerprint(original)!!
)
val session = RefreshWriteSession(snapshot)
val rotated = original.copy(
accessToken = "access-new",
refreshToken = "refresh-new",
expiresAt = 2000L,
lastVerifiedAt = 100L
)
session.stageCredential(rotated)
session.stageCredential(original.copy(lastVerifiedAt = 200L))
val staged = session.stagedCredential() as Credential.CodexCredential
assertEquals("access-new", staged.accessToken)
assertEquals("refresh-new", staged.refreshToken)
assertEquals(2000L, staged.expiresAt)
assertEquals(200L, staged.lastVerifiedAt)
}
@Test
fun `latest changed credential wins when token rotates more than once`() {
val original = codexCredential()
val session = RefreshWriteSession(
CredentialRepository.CredentialSnapshot(
credential = original,
revision = 1L,
fingerprint = CredentialRepository.credentialFingerprint(original)!!
)
)
session.stageCredential(original.copy(accessToken = "access-1", refreshToken = "refresh-1"))
session.stageCredential(original.copy(accessToken = "access-2", refreshToken = "refresh-2"))
val staged = session.stagedCredential() as Credential.CodexCredential
assertEquals("access-2", staged.accessToken)
assertEquals("refresh-2", staged.refreshToken)
}
@Test
fun `balance write is staged for atomic commit`() {
val credential = Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "sk-test")
val session = RefreshWriteSession(
CredentialRepository.CredentialSnapshot(
credential = credential,
revision = 0L,
fingerprint = CredentialRepository.credentialFingerprint(credential)!!
)
)
val balance = ServiceBalance(
service = ServiceType.DEEPSEEK,
amount = 12.34,
unit = "¥"
)
session.stageBalance(ServiceType.DEEPSEEK, balance)
assertNotNull(session.stagedBalance())
assertEquals(balance, session.stagedBalance())
}
@Test
fun `session reads staged rotated credential on retry`() {
val original = codexCredential()
val session = RefreshWriteSession(
CredentialRepository.CredentialSnapshot(
credential = original,
revision = 2L,
fingerprint = CredentialRepository.credentialFingerprint(original)!!
)
)
val rotated = original.copy(
accessToken = "access-new",
refreshToken = "refresh-new"
)
session.stageCredential(rotated)
assertEquals(rotated, session.credentialForRead())
}
@Test
fun `snapshot must match both revision and fingerprint`() {
assertTrue(CredentialRepository.snapshotMatches(3L, "fp-a", 3L, "fp-a"))
assertFalse(CredentialRepository.snapshotMatches(3L, "fp-a", 4L, "fp-a"))
assertFalse(CredentialRepository.snapshotMatches(3L, "fp-a", 3L, "fp-b"))
}
@Test
fun `Codex token rotation changes auth fingerprint but keeps cache identity`() {
val original = codexCredential()
val rotated = original.copy(
accessToken = "access-new",
refreshToken = "refresh-new",
expiresAt = 2000L
)
assertNotEquals(
CredentialRepository.credentialFingerprint(original),
CredentialRepository.credentialFingerprint(rotated)
)
assertEquals(
CredentialRepository.cacheIdentityFingerprint(original),
CredentialRepository.cacheIdentityFingerprint(rotated)
)
assertTrue(CredentialRepository.sameRefreshLineage(original, rotated))
}
@Test
fun `Codex rotation without account id remains same refresh lineage`() {
val original = codexCredential().copy(accountId = "")
val rotated = original.copy(
accessToken = "access-new",
refreshToken = "refresh-new",
expiresAt = 2000L
)
// Without accountId the conservative cache identity changes, but the refresh response
// is still a copy of the same credential lineage and its one-time token must be persisted.
assertNotEquals(
CredentialRepository.cacheIdentityFingerprint(original),
CredentialRepository.cacheIdentityFingerprint(rotated)
)
assertTrue(CredentialRepository.sameRefreshLineage(original, rotated))
}
@Test
fun `different Codex account is not same refresh lineage`() {
val original = codexCredential()
val differentAccount = original.copy(
accessToken = "other-access",
refreshToken = "other-refresh",
accountId = "account-2"
)
assertFalse(CredentialRepository.sameRefreshLineage(original, differentAccount))
}
@Test
fun `changing balance account invalidates cache identity`() {
val oldCredential = Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "key-a")
val newCredential = oldCredential.copy(key = "key-b")
assertNotEquals(
CredentialRepository.cacheIdentityFingerprint(oldCredential),
CredentialRepository.cacheIdentityFingerprint(newCredential)
)
}
private fun codexCredential(): Credential.CodexCredential =
Credential.CodexCredential(
service = ServiceType.CODEX,
accessToken = "access-old",
refreshToken = "refresh-old",
accountId = "account-1",
expiresAt = 1000L
)
}

View File

@ -0,0 +1,201 @@
package com.rainy.token.ui.servicedetail
import com.rainy.token.domain.model.CookieEntry
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Tests credential fingerprint computation and credential change classification.
*/
class ServiceDetailViewModelTest {
@Test
fun `null credential returns null fingerprint`() {
assertNull(ServiceDetailViewModel.credentialFingerprint(null))
}
@Test
fun `same ApiKeyCredential ignores lastVerifiedAt`() {
val cred = Credential.ApiKeyCredential(
service = ServiceType.DEEPSEEK,
key = "sk-abc123",
lastVerifiedAt = 1000L
)
val fp1 = ServiceDetailViewModel.credentialFingerprint(cred)
val fp2 = ServiceDetailViewModel.credentialFingerprint(cred.copy(lastVerifiedAt = 2000L))
assertEquals(fp1, fp2)
assertEquals(64, fp1?.length)
assertFalse(fp1.orEmpty().contains(cred.key))
}
@Test
fun `different ApiKeyCredential keys produce different fingerprints`() {
val fp1 = ServiceDetailViewModel.credentialFingerprint(
Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "key-A")
)
val fp2 = ServiceDetailViewModel.credentialFingerprint(
Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "key-B")
)
assertNotEquals(fp1, fp2)
}
@Test
fun `Codex fingerprint varies with access token`() {
val cred1 = codexCredential()
val cred2 = cred1.copy(accessToken = "at-BBB")
assertNotEquals(
ServiceDetailViewModel.credentialFingerprint(cred1),
ServiceDetailViewModel.credentialFingerprint(cred2)
)
}
@Test
fun `Codex fingerprint varies when only refresh token is rotated`() {
val cred1 = codexCredential()
val cred2 = cred1.copy(refreshToken = "rt-ROTATED")
assertNotEquals(
ServiceDetailViewModel.credentialFingerprint(cred1),
ServiceDetailViewModel.credentialFingerprint(cred2)
)
}
@Test
fun `Session fingerprint varies with authCookie`() {
val cred1 = Credential.SessionCredential(
service = ServiceType.OPENCODE_GO,
authCookie = "cookie-AAA"
)
val cred2 = cred1.copy(authCookie = "cookie-BBB")
assertNotEquals(
ServiceDetailViewModel.credentialFingerprint(cred1),
ServiceDetailViewModel.credentialFingerprint(cred2)
)
}
@Test
fun `Session fingerprint includes Cookie list and is order independent`() {
val cookieA = CookieEntry(name = "a", value = "1", domain = "example.com")
val cookieB = CookieEntry(name = "b", value = "2", domain = "example.com")
val cred1 = Credential.SessionCredential(
service = ServiceType.OLLAMA,
cookies = listOf(cookieA, cookieB)
)
val reordered = cred1.copy(cookies = listOf(cookieB, cookieA))
val changed = cred1.copy(cookies = listOf(cookieA, cookieB.copy(value = "3")))
assertEquals(
ServiceDetailViewModel.credentialFingerprint(cred1),
ServiceDetailViewModel.credentialFingerprint(reordered)
)
assertNotEquals(
ServiceDetailViewModel.credentialFingerprint(cred1),
ServiceDetailViewModel.credentialFingerprint(changed)
)
}
@Test
fun `null to null is NONE_TO_NONE`() {
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.NONE_TO_NONE,
ServiceDetailViewModel.classifyCredentialChange(null, null)
)
}
@Test
fun `null to fingerprint is NEW`() {
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.NEW,
ServiceDetailViewModel.classifyCredentialChange(null, "fp-1")
)
}
@Test
fun `fingerprint to null is DELETED`() {
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.DELETED,
ServiceDetailViewModel.classifyCredentialChange("fp-1", null)
)
}
@Test
fun `same fingerprint is UNCHANGED`() {
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.UNCHANGED,
ServiceDetailViewModel.classifyCredentialChange("fp-1", "fp-1")
)
}
@Test
fun `different fingerprints is REPLACED`() {
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.REPLACED,
ServiceDetailViewModel.classifyCredentialChange("fp-1", "fp-2")
)
}
@Test
fun `invalid credential replaced by valid credential is REPLACED`() {
val oldFp = ServiceDetailViewModel.credentialFingerprint(
Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "invalid-key")
)
val newFp = ServiceDetailViewModel.credentialFingerprint(
Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "valid-key")
)
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.REPLACED,
ServiceDetailViewModel.classifyCredentialChange(oldFp, newFp)
)
}
@Test
fun `account A replaced by account B is REPLACED`() {
val accountA = ServiceDetailViewModel.credentialFingerprint(codexCredential())
val accountB = ServiceDetailViewModel.credentialFingerprint(
codexCredential().copy(
accessToken = "token-B",
refreshToken = "rt-B",
accountId = "acc-B"
)
)
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.REPLACED,
ServiceDetailViewModel.classifyCredentialChange(accountA, accountB)
)
}
@Test
fun `credential deleted during refresh is DELETED`() {
val oldFp = ServiceDetailViewModel.credentialFingerprint(
Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "key-1")
)
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.DELETED,
ServiceDetailViewModel.classifyCredentialChange(oldFp, null)
)
}
@Test
fun `normal resume with unchanged credential is UNCHANGED`() {
val fp = ServiceDetailViewModel.credentialFingerprint(
Credential.ApiKeyCredential(ServiceType.DEEPSEEK, "same-key")
)
assertEquals(
ServiceDetailViewModel.Companion.CredentialChange.UNCHANGED,
ServiceDetailViewModel.classifyCredentialChange(fp, fp)
)
}
private fun codexCredential(): Credential.CodexCredential =
Credential.CodexCredential(
service = ServiceType.CODEX,
accessToken = "at-AAA",
refreshToken = "rt-AAA",
accountId = "acc-1",
expiresAt = 1000L
)
}