diff --git a/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt b/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt index 8d606ae..52247c1 100644 --- a/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt +++ b/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt @@ -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 { 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 { + if (raw == null) return emptyMap() + return runCatching { + json.decodeFromString( + MapSerializer(ServiceType.serializer(), CachedBalance.serializer()), + raw + ) + }.getOrDefault(emptyMap()) + } + + private fun encode(value: Map): 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 by preferencesDataStore( name = "balance_cache" -) \ No newline at end of file +) diff --git a/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt index 23335f1..4b011ec 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt @@ -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() + private val cacheRollbackEntries = mutableMapOf() + 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 = + 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 = + 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 = - ServiceConfigProvider.all().map { statusFor(it.type) } -} \ No newline at end of file + /** + * 判断认证更新是否来自同一条合法刷新链。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.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) + } + } +} diff --git a/app/src/main/java/com/rainy/token/data/repository/RefreshWriteSession.kt b/app/src/main/java/com/rainy/token/data/repository/RefreshWriteSession.kt new file mode 100644 index 0000000..bfd6854 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/RefreshWriteSession.kt @@ -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 + + 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) +} diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index c6ed324..b0c80bd 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -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 ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index ade5b6d..b943352 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -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, private val openCodeGoRepositoryProvider: Provider, private val commandCodeGoRepositoryProvider: Provider, private val codexRepositoryProvider: Provider, private val ollamaRepositoryProvider: Provider ) { - suspend operator fun invoke(service: ServiceType): Result = 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 = + 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> = codexRepositoryProvider.get().fetchModels() - /** 一键激活 Codex 用量:向 ChatGPT API 发送请求,返回完整响应体 */ suspend fun triggerCodexUsage(model: String): Result = - codexRepositoryProvider.get().triggerUsage(model) + withCredentialSession(ServiceType.CODEX) { + codexRepositoryProvider.get().triggerUsage(model) + } - /** 获取 OpenCode Go 可用模型列表 */ suspend fun fetchOpenCodeGoModels(): Result> = openCodeGoRepositoryProvider.get().fetchModels() - /** 一键激活 OpenCode Go 用量 */ suspend fun triggerOpenCodeGoUsage(model: String): Result = - openCodeGoRepositoryProvider.get().triggerUsage(model) + withCredentialSession(ServiceType.OPENCODE_GO) { + openCodeGoRepositoryProvider.get().triggerUsage(model) + } - /** 获取 Ollama Cloud 可用模型列表 */ suspend fun fetchOllamaModels(): Result> = ollamaRepositoryProvider.get().fetchModels() - /** 一键激活 Ollama Cloud 用量 */ suspend fun triggerOllamaUsage(model: String): Result = - ollamaRepositoryProvider.get().triggerUsage(model) -} \ No newline at end of file + withCredentialSession(ServiceType.OLLAMA) { + ollamaRepositoryProvider.get().triggerUsage(model) + } + + private suspend fun withCredentialSession( + service: ServiceType, + block: suspend () -> Result + ): Result { + 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 语义;提交失败不会伪装成业务成功。 + } + } + } +} diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 06dc033..49b49cc 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -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.Empty) } + val dashboardVm: DashboardViewModel = hiltViewModel() + + // 宽屏布局下 DashboardScreen 永久挂载,不会收到 ON_RESUME。 + // 当详情面板关闭时手动触发 Dashboard 刷新凭据状态。 + var prevPane by remember { mutableStateOf(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", diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 8452771..0cdc65c 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -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) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index bc0f157..fa6489b 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -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 = _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 -> "正常" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index a0262e2..b323d67 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -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() diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index 1804a68..2aacd16 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -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 = _uiState.asStateFlow() - /** Codex 一键激活用量状态 */ private val _triggerState = MutableStateFlow(TriggerState.Idle) val triggerState: StateFlow = _triggerState.asStateFlow() - /** 可用模型列表(Codex / OCGO / Ollama 共用) */ private val _models = MutableStateFlow>(emptyList()) val models: StateFlow> = _models.asStateFlow() - /** 用户选中的模型(持久化) */ private val _selectedModel = MutableStateFlow(null) val selectedModel: StateFlow = _selectedModel.asStateFlow() - /** 模型列表加载状态 */ private val _modelsLoading = MutableStateFlow(false) val modelsLoading: StateFlow = _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) { prefs(service).edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply() } private fun loadModelsCache(service: ServiceType): List = - 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() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 80d7f61..ea50b05 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -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, - private val commandCodeGoRepositoryProvider: Provider, - private val refreshBalanceUseCaseProvider: Provider, - private val ollamaRepositoryProvider: Provider + private val refreshBalanceUseCaseProvider: Provider ) : 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 Result>, @@ -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 -) \ No newline at end of file +) diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index abbc6aa..f0e8039 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -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( diff --git a/app/src/test/java/com/rainy/token/data/repository/CredentialRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CredentialRepositoryTest.kt new file mode 100644 index 0000000..319985e --- /dev/null +++ b/app/src/test/java/com/rainy/token/data/repository/CredentialRepositoryTest.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/rainy/token/data/repository/RefreshWriteSessionTest.kt b/app/src/test/java/com/rainy/token/data/repository/RefreshWriteSessionTest.kt new file mode 100644 index 0000000..6159c8f --- /dev/null +++ b/app/src/test/java/com/rainy/token/data/repository/RefreshWriteSessionTest.kt @@ -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 + ) +} diff --git a/app/src/test/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModelTest.kt b/app/src/test/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModelTest.kt new file mode 100644 index 0000000..b89c6e4 --- /dev/null +++ b/app/src/test/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModelTest.kt @@ -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 + ) +}