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:
parent
74a4e3d494
commit
2732b138ae
@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 语义;提交失败不会伪装成业务成功。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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 -> "正常"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user