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.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.rainy.token.data.repository.RefreshWriteSession
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.serialization.builtins.MapSerializer
|
import kotlinx.serialization.builtins.MapSerializer
|
||||||
@ -31,35 +33,46 @@ class BalanceCache(
|
|||||||
|
|
||||||
suspend fun getAll(): Map<ServiceType, CachedBalance> {
|
suspend fun getAll(): Map<ServiceType, CachedBalance> {
|
||||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap()
|
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap()
|
||||||
return runCatching {
|
return decode(raw)
|
||||||
val map = json.decodeFromString(
|
|
||||||
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
|
|
||||||
raw
|
|
||||||
)
|
|
||||||
map
|
|
||||||
}.getOrDefault(emptyMap())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
|
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
|
||||||
|
|
||||||
suspend fun put(service: ServiceType, balance: ServiceBalance) {
|
suspend fun put(service: ServiceType, balance: ServiceBalance) {
|
||||||
// 在 dataStore.edit 的互斥锁内做 read-modify-write,避免并发覆盖
|
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 ->
|
dataStore.edit { prefs ->
|
||||||
val raw = prefs[cacheKey]
|
val updated = decode(prefs[cacheKey]).toMutableMap()
|
||||||
val current = if (raw != null) {
|
updated[service] = cachedBalance
|
||||||
runCatching {
|
prefs[cacheKey] = encode(updated)
|
||||||
json.decodeFromString(
|
}
|
||||||
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
|
}
|
||||||
raw
|
|
||||||
)
|
/** 删除单个服务缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */
|
||||||
}.getOrDefault(emptyMap())
|
suspend fun remove(service: ServiceType) {
|
||||||
} else emptyMap()
|
dataStore.edit { prefs ->
|
||||||
|
val current = decode(prefs[cacheKey])
|
||||||
|
if (service !in current) return@edit
|
||||||
val updated = current.toMutableMap()
|
val updated = current.toMutableMap()
|
||||||
updated[service] = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis())
|
updated.remove(service)
|
||||||
prefs[cacheKey] = json.encodeToString(
|
if (updated.isEmpty()) {
|
||||||
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
|
prefs.remove(cacheKey)
|
||||||
updated
|
} else {
|
||||||
)
|
prefs[cacheKey] = encode(updated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +80,22 @@ class BalanceCache(
|
|||||||
dataStore.edit { it.remove(cacheKey) }
|
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 {
|
companion object {
|
||||||
val DefaultJson = Json {
|
val DefaultJson = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
|
|||||||
@ -1,26 +1,278 @@
|
|||||||
package com.rainy.token.data.repository
|
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.data.local.SecureStorage
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import kotlinx.serialization.builtins.serializer
|
import java.security.MessageDigest
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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
|
@Singleton
|
||||||
class CredentialRepository @Inject constructor(
|
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}"
|
private fun keyFor(service: ServiceType): String = "credential_${service.storageKey}"
|
||||||
|
|
||||||
suspend fun save(credential: Credential) {
|
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(
|
secureStorage.put(
|
||||||
key = keyFor(credential.service),
|
key = keyFor(credential.service),
|
||||||
value = credential,
|
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(
|
secureStorage.get(
|
||||||
key = keyFor(service),
|
key = keyFor(service),
|
||||||
serializer = Credential.serializer()
|
serializer = Credential.serializer()
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun remove(service: ServiceType) {
|
private fun revisionFor(service: ServiceType): Long = revisions[service] ?: 0L
|
||||||
secureStorage.remove(keyFor(service))
|
|
||||||
|
private fun bumpRevision(service: ServiceType) {
|
||||||
|
revisions[service] = revisionFor(service) + 1L
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取并转换为 UI 用的 [CredentialStatus]。未配置/已删除都返回 NOT_CONFIGURED。
|
* 判断认证更新是否来自同一条合法刷新链。Codex 的 refresh 响应由旧凭据 copy
|
||||||
|
* 产生,因此 accountId(即使为空)必须保持一致;其他类型要求余额账户身份一致。
|
||||||
*/
|
*/
|
||||||
suspend fun statusFor(service: ServiceType): CredentialStatus {
|
internal fun sameRefreshLineage(
|
||||||
val credential = get(service) ?: return CredentialStatus(
|
original: Credential,
|
||||||
service = service,
|
updated: Credential
|
||||||
state = CredentialStatus.State.NOT_CONFIGURED,
|
): Boolean = when {
|
||||||
lastVerifiedAt = 0L
|
original is Credential.CodexCredential && updated is Credential.CodexCredential ->
|
||||||
)
|
original.service == updated.service && original.accountId == updated.accountId
|
||||||
val now = System.currentTimeMillis()
|
else -> cacheIdentityFingerprint(original) == cacheIdentityFingerprint(updated)
|
||||||
// 简单启发:最近 7 天内有验证 → OK;否则按 lastVerifiedAt 是否为 0 判断
|
|
||||||
val state = when {
|
|
||||||
credential.lastVerifiedAt == 0L -> CredentialStatus.State.WARNING
|
|
||||||
now - credential.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 的短期 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) :
|
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
|
||||||
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
|
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
|
||||||
|
|
||||||
|
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
|
||||||
|
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")
|
||||||
|
|
||||||
/** 限流(429 Too Many Requests) */
|
/** 限流(429 Too Many Requests) */
|
||||||
class RateLimited(val retryAfterSeconds: Long? = null) :
|
class RateLimited(val retryAfterSeconds: Long? = null) :
|
||||||
RepositoryError("请求过于频繁${retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}")
|
RepositoryError("请求过于频繁${retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}")
|
||||||
|
|||||||
@ -2,63 +2,128 @@ package com.rainy.token.domain.usecase
|
|||||||
|
|
||||||
import com.rainy.token.data.repository.CodexRepository
|
import com.rainy.token.data.repository.CodexRepository
|
||||||
import com.rainy.token.data.repository.CommandCodeGoRepository
|
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.DeepSeekRepository
|
||||||
import com.rainy.token.data.repository.OllamaRepository
|
import com.rainy.token.data.repository.OllamaRepository
|
||||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
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.data.repository.retryOnTransientError
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 唯一的 UseCase。计划架构补充说明:
|
* 唯一的余额刷新 UseCase。
|
||||||
* - 内部按 ServiceType 分发到对应 Repository
|
*
|
||||||
* - 现阶段实现:DeepSeek(REST API) + OpenCode Go(OkHttp 抓 dashboard)+ CommandCode Go(JSON API)
|
* Repository 中的凭据/缓存写入先暂存;请求结束后校验起始凭据快照仍为当前版本才提交,
|
||||||
* + 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 不重试。
|
|
||||||
*/
|
*/
|
||||||
class RefreshBalanceUseCase @Inject constructor(
|
class RefreshBalanceUseCase @Inject constructor(
|
||||||
|
private val credentialRepository: CredentialRepository,
|
||||||
private val deepSeekRepositoryProvider: Provider<DeepSeekRepository>,
|
private val deepSeekRepositoryProvider: Provider<DeepSeekRepository>,
|
||||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
||||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
||||||
private val codexRepositoryProvider: Provider<CodexRepository>,
|
private val codexRepositoryProvider: Provider<CodexRepository>,
|
||||||
private val ollamaRepositoryProvider: Provider<OllamaRepository>
|
private val ollamaRepositoryProvider: Provider<OllamaRepository>
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> = when (service) {
|
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> =
|
||||||
ServiceType.DEEPSEEK -> retryOnTransientError { deepSeekRepositoryProvider.get().fetchBalance() }
|
withCredentialSession(service) {
|
||||||
ServiceType.OPENCODE_GO -> retryOnTransientError { openCodeGoRepositoryProvider.get().fetchBalance() }
|
when (service) {
|
||||||
ServiceType.COMMANDCODE_GO -> retryOnTransientError { commandCodeGoRepositoryProvider.get().fetchBalance() }
|
ServiceType.DEEPSEEK -> retryOnTransientError {
|
||||||
ServiceType.CODEX -> retryOnTransientError { codexRepositoryProvider.get().fetchBalance() }
|
deepSeekRepositoryProvider.get().fetchBalance()
|
||||||
ServiceType.OLLAMA -> retryOnTransientError { ollamaRepositoryProvider.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>> =
|
suspend fun fetchCodexModels(): Result<List<String>> =
|
||||||
codexRepositoryProvider.get().fetchModels()
|
codexRepositoryProvider.get().fetchModels()
|
||||||
|
|
||||||
/** 一键激活 Codex 用量:向 ChatGPT API 发送请求,返回完整响应体 */
|
|
||||||
suspend fun triggerCodexUsage(model: String): Result<String> =
|
suspend fun triggerCodexUsage(model: String): Result<String> =
|
||||||
|
withCredentialSession(ServiceType.CODEX) {
|
||||||
codexRepositoryProvider.get().triggerUsage(model)
|
codexRepositoryProvider.get().triggerUsage(model)
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取 OpenCode Go 可用模型列表 */
|
|
||||||
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
|
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
|
||||||
openCodeGoRepositoryProvider.get().fetchModels()
|
openCodeGoRepositoryProvider.get().fetchModels()
|
||||||
|
|
||||||
/** 一键激活 OpenCode Go 用量 */
|
|
||||||
suspend fun triggerOpenCodeGoUsage(model: String): Result<String> =
|
suspend fun triggerOpenCodeGoUsage(model: String): Result<String> =
|
||||||
|
withCredentialSession(ServiceType.OPENCODE_GO) {
|
||||||
openCodeGoRepositoryProvider.get().triggerUsage(model)
|
openCodeGoRepositoryProvider.get().triggerUsage(model)
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取 Ollama Cloud 可用模型列表 */
|
|
||||||
suspend fun fetchOllamaModels(): Result<List<String>> =
|
suspend fun fetchOllamaModels(): Result<List<String>> =
|
||||||
ollamaRepositoryProvider.get().fetchModels()
|
ollamaRepositoryProvider.get().fetchModels()
|
||||||
|
|
||||||
/** 一键激活 Ollama Cloud 用量 */
|
|
||||||
suspend fun triggerOllamaUsage(model: String): Result<String> =
|
suspend fun triggerOllamaUsage(model: String): Result<String> =
|
||||||
|
withCredentialSession(ServiceType.OLLAMA) {
|
||||||
ollamaRepositoryProvider.get().triggerUsage(model)
|
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.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
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.DebugLogScreen
|
||||||
import com.rainy.token.ui.components.TipsScreen
|
import com.rainy.token.ui.components.TipsScreen
|
||||||
import com.rainy.token.ui.dashboard.DashboardScreen
|
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.UsageChartViewModel
|
||||||
import com.rainy.token.ui.dashboard.UsageDataScreen
|
import com.rainy.token.ui.dashboard.UsageDataScreen
|
||||||
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
||||||
@ -309,6 +311,17 @@ private fun CompactNavHost() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ExpandedLayout() {
|
private fun ExpandedLayout() {
|
||||||
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
|
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()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
@ -320,7 +333,8 @@ private fun ExpandedLayout() {
|
|||||||
onOpenSettings = { detailPane = DetailPane.Settings },
|
onOpenSettings = { detailPane = DetailPane.Settings },
|
||||||
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
|
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
|
||||||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||||||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }
|
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
||||||
|
viewModel = dashboardVm
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,7 +351,8 @@ private fun ExpandedLayout() {
|
|||||||
) {
|
) {
|
||||||
ExpandedDetailPane(
|
ExpandedDetailPane(
|
||||||
pane = detailPane,
|
pane = detailPane,
|
||||||
onClose = { detailPane = DetailPane.Empty }
|
onClose = { detailPane = DetailPane.Empty },
|
||||||
|
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -346,7 +361,8 @@ private fun ExpandedLayout() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ExpandedDetailPane(
|
private fun ExpandedDetailPane(
|
||||||
pane: DetailPane,
|
pane: DetailPane,
|
||||||
onClose: () -> Unit
|
onClose: () -> Unit,
|
||||||
|
onCredentialEditClosed: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
when (pane) {
|
when (pane) {
|
||||||
is DetailPane.Empty -> {
|
is DetailPane.Empty -> {
|
||||||
@ -426,6 +442,17 @@ private fun ExpandedDetailPane(
|
|||||||
}
|
}
|
||||||
is DetailPane.Settings -> {
|
is DetailPane.Settings -> {
|
||||||
val settingsNavController = rememberNavController()
|
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(
|
NavHost(
|
||||||
navController = settingsNavController,
|
navController = settingsNavController,
|
||||||
startDestination = "settings_main",
|
startDestination = "settings_main",
|
||||||
|
|||||||
@ -74,6 +74,8 @@ import androidx.compose.ui.zIndex
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
@ -118,6 +120,11 @@ fun DashboardScreen(
|
|||||||
onOpenCcgoUsageDetail: () -> Unit = {},
|
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||||
viewModel: DashboardViewModel = hiltViewModel()
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
|
// 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求)
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
viewModel.reloadLocalState()
|
||||||
|
}
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showAddWidgetConfirm by remember { mutableStateOf(false) }
|
var showAddWidgetConfirm by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@ -3,10 +3,9 @@ package com.rainy.token.ui.dashboard
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rainy.token.data.cache.BalanceCache
|
|
||||||
import com.rainy.token.data.cache.CachedBalance
|
import com.rainy.token.data.cache.CachedBalance
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
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.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
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 com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@ -23,20 +23,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仪表盘 ViewModel。
|
* 仪表盘 ViewModel。
|
||||||
*
|
*
|
||||||
* 状态聚合:凭据状态 + 余额缓存 + 在线刷新
|
* 状态聚合:凭据状态 + 余额缓存 + 在线刷新
|
||||||
* - 启动时读缓存展示(无网时也能看)
|
* - 启动时读缓存展示(无网时也能看旧数据)
|
||||||
* - refresh() 并行拉取所有服务的最新余额(任一失败不影响其他)
|
* - refresh() 并行拉取所有服务的最新余额(任一失败不影响其他)
|
||||||
* - 下拉刷新触发同一 refresh()
|
* - 下拉刷新触发同一 refresh()
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val balanceCache: BalanceCache,
|
|
||||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||||
@param:ApplicationContext private val appContext: Context
|
@param:ApplicationContext private val appContext: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@ -44,7 +42,7 @@ class DashboardViewModel @Inject constructor(
|
|||||||
private val _uiState = MutableStateFlow(DashboardUiState())
|
private val _uiState = MutableStateFlow(DashboardUiState())
|
||||||
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
/** 防止并发 refresh() 调用交错覆盖 UI */
|
/** 防止并发 refresh() 调用交错覆盖 UI。 */
|
||||||
private val refreshMutex = Mutex()
|
private val refreshMutex = Mutex()
|
||||||
|
|
||||||
init {
|
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() {
|
private suspend fun loadFromCache() {
|
||||||
val cached = balanceCache.getAll()
|
val localStates = credentialRepository.readLocalStates()
|
||||||
val cards = ServiceType.entries.map { type ->
|
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) }
|
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上 */
|
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上。 */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
|
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
|
||||||
@ -84,11 +123,14 @@ class DashboardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}.awaitAll().toMap()
|
}.awaitAll().toMap()
|
||||||
}
|
}
|
||||||
val newCache = balanceCache.getAll()
|
val localStates = credentialRepository.readLocalStates()
|
||||||
val cards = ServiceType.entries.map { type ->
|
val cards = ServiceType.entries.map { type ->
|
||||||
val result = results[type]
|
val result = results[type]
|
||||||
val errMsg = result?.exceptionOrNull()?.message
|
val error = result?.exceptionOrNull()
|
||||||
buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg)
|
val errMsg = error
|
||||||
|
?.takeUnless { it is RepositoryError.CredentialChanged }
|
||||||
|
?.message
|
||||||
|
buildCard(localStates.getValue(type), lastFetchError = errMsg)
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
||||||
// 刷新成功后更新桌面小组件
|
// 刷新成功后更新桌面小组件
|
||||||
@ -101,19 +143,31 @@ class DashboardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildCard(
|
private fun buildCard(
|
||||||
type: ServiceType,
|
local: CredentialRepository.LocalState,
|
||||||
cachedBalance: CachedBalance?,
|
|
||||||
lastFetchError: String?
|
lastFetchError: String?
|
||||||
): DashboardCardUi {
|
): DashboardCardUi = DashboardCardUi(
|
||||||
val status = credentialRepository.statusFor(type)
|
service = local.status.service,
|
||||||
return DashboardCardUi(
|
credentialState = local.status.state,
|
||||||
service = type,
|
credentialFingerprint = local.fingerprint,
|
||||||
credentialState = status.state,
|
cachedBalance = local.cachedBalance,
|
||||||
cachedBalance = cachedBalance,
|
|
||||||
lastFetchError = lastFetchError
|
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(
|
data class DashboardUiState(
|
||||||
@ -125,13 +179,14 @@ data class DashboardUiState(
|
|||||||
data class DashboardCardUi(
|
data class DashboardCardUi(
|
||||||
val service: ServiceType,
|
val service: ServiceType,
|
||||||
val credentialState: CredentialStatus.State,
|
val credentialState: CredentialStatus.State,
|
||||||
|
val credentialFingerprint: String?,
|
||||||
val cachedBalance: CachedBalance?,
|
val cachedBalance: CachedBalance?,
|
||||||
val lastFetchError: String?
|
val lastFetchError: String?
|
||||||
) {
|
) {
|
||||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示) */
|
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||||
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
||||||
|
|
||||||
/** 卡片顶部状态徽章 */
|
/** 卡片顶部状态徽章。 */
|
||||||
val statusBadge: String get() = when {
|
val statusBadge: String get() = when {
|
||||||
credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置"
|
credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置"
|
||||||
lastFetchError != null -> "刷新失败"
|
lastFetchError != null -> "刷新失败"
|
||||||
|
|||||||
@ -55,6 +55,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.FetchMethod
|
import com.rainy.token.domain.service.FetchMethod
|
||||||
@ -94,6 +96,10 @@ fun ServiceDetailScreen(
|
|||||||
viewModel: ServiceDetailViewModel = hiltViewModel()
|
viewModel: ServiceDetailViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(service) { viewModel.bind(service) }
|
LaunchedEffect(service) { viewModel.bind(service) }
|
||||||
|
// 从凭据编辑页返回时重新读取凭据状态 + 缓存
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
viewModel.reloadCredentialState()
|
||||||
|
}
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val triggerState by viewModel.triggerState.collectAsStateWithLifecycle()
|
val triggerState by viewModel.triggerState.collectAsStateWithLifecycle()
|
||||||
val models by viewModel.models.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.CredentialRepository
|
||||||
import com.rainy.token.data.repository.RepositoryError
|
import com.rainy.token.data.repository.RepositoryError
|
||||||
import com.rainy.token.data.repository.TriggerError
|
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.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
/** 服务详情页 ViewModel。 */
|
||||||
* 服务详情页 ViewModel。计划 3.3:UiState = Loading | Fresh | Stale | Error
|
|
||||||
* 阶段 3 实现:DeepSeek 的真实刷新。其他服务调用 RefreshBalanceUseCase 会得到
|
|
||||||
* UnsupportedServiceException,进入"暂未支持"提示状态。
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ServiceDetailViewModel @Inject constructor(
|
class ServiceDetailViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
@ -36,28 +33,49 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
private val _uiState = MutableStateFlow(ServiceDetailUiState())
|
private val _uiState = MutableStateFlow(ServiceDetailUiState())
|
||||||
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
/** Codex 一键激活用量状态 */
|
|
||||||
private val _triggerState = MutableStateFlow<TriggerState>(TriggerState.Idle)
|
private val _triggerState = MutableStateFlow<TriggerState>(TriggerState.Idle)
|
||||||
val triggerState: StateFlow<TriggerState> = _triggerState.asStateFlow()
|
val triggerState: StateFlow<TriggerState> = _triggerState.asStateFlow()
|
||||||
|
|
||||||
/** 可用模型列表(Codex / OCGO / Ollama 共用) */
|
|
||||||
private val _models = MutableStateFlow<List<String>>(emptyList())
|
private val _models = MutableStateFlow<List<String>>(emptyList())
|
||||||
val models: StateFlow<List<String>> = _models.asStateFlow()
|
val models: StateFlow<List<String>> = _models.asStateFlow()
|
||||||
|
|
||||||
/** 用户选中的模型(持久化) */
|
|
||||||
private val _selectedModel = MutableStateFlow<String?>(null)
|
private val _selectedModel = MutableStateFlow<String?>(null)
|
||||||
val selectedModel: StateFlow<String?> = _selectedModel.asStateFlow()
|
val selectedModel: StateFlow<String?> = _selectedModel.asStateFlow()
|
||||||
|
|
||||||
/** 模型列表加载状态 */
|
|
||||||
private val _modelsLoading = MutableStateFlow(false)
|
private val _modelsLoading = MutableStateFlow(false)
|
||||||
val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
|
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 {
|
companion object {
|
||||||
private const val CODEX_PREFS = "codex_trigger_prefs"
|
private const val CODEX_PREFS = "codex_trigger_prefs"
|
||||||
private const val KEY_SELECTED_MODEL = "selected_model"
|
private const val KEY_SELECTED_MODEL = "selected_model"
|
||||||
private const val KEY_MODELS_CACHE = "models_cache"
|
private const val KEY_MODELS_CACHE = "models_cache"
|
||||||
private const val OCGO_PREFS = "ocgo_trigger_prefs"
|
private const val OCGO_PREFS = "ocgo_trigger_prefs"
|
||||||
private const val OLLAMA_PREFS = "ollama_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) {
|
private fun prefsNameFor(service: ServiceType): String = when (service) {
|
||||||
@ -69,28 +87,95 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun bind(service: ServiceType) {
|
fun bind(service: ServiceType) {
|
||||||
if (_serviceType.value == service) return
|
if (_serviceType.value == service) return
|
||||||
|
|
||||||
|
serviceGeneration++
|
||||||
|
refreshGeneration++
|
||||||
_serviceType.value = service
|
_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) {
|
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
|
||||||
val savedModel = loadSelectedModel(service)
|
loadSelectedModel(service)?.let { _selectedModel.value = it }
|
||||||
if (savedModel != null) _selectedModel.value = savedModel
|
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
|
||||||
val cachedModels = loadModelsCache(service)
|
|
||||||
if (cachedModels.isNotEmpty()) _models.value = cachedModels
|
|
||||||
}
|
}
|
||||||
loadFromCache()
|
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() {
|
fun refresh() {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
|
val serviceGen = serviceGeneration
|
||||||
|
val gen = ++refreshGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val requestFingerprint = credentialRepository.readLocalState(type).fingerprint
|
||||||
|
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||||
_uiState.update { it.copy(state = State.Loading) }
|
_uiState.update { it.copy(state = State.Loading) }
|
||||||
|
|
||||||
// 手动输入模式:直接展示用户上次输入的余额(如果有)
|
|
||||||
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
||||||
if (config.method == com.rainy.token.domain.service.FetchMethod.MANUAL) {
|
if (config.method == com.rainy.token.domain.service.FetchMethod.MANUAL) {
|
||||||
val cached = balanceCache.get(type)
|
val cached = balanceCache.get(type)
|
||||||
|
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
cached = newerOf(it.cached, cached),
|
||||||
state = if (cached != null) {
|
state = if (cached != null) {
|
||||||
State.Fresh(cached.balance)
|
State.Fresh(cached.balance)
|
||||||
} else {
|
} else {
|
||||||
@ -102,14 +187,60 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val result = refreshBalanceUseCase(type)
|
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
|
result
|
||||||
.onSuccess { balance -> _uiState.update { it.copy(state = State.Fresh(balance)) } }
|
.onSuccess { balance ->
|
||||||
.onFailure { error ->
|
// 认证字段可能因 Codex Token 轮换而变化,也可能是用户在请求完成后
|
||||||
val cached = balanceCache.get(type)
|
// 切换了账户。两种情况下只信任当前凭据对应的持久化缓存;绝不把
|
||||||
|
// 请求结果中的旧账户余额重新挂到新凭据上。
|
||||||
|
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 {
|
_uiState.update {
|
||||||
|
val mergedCache = if (fingerprintChanged) {
|
||||||
|
local.cachedBalance
|
||||||
|
} else {
|
||||||
|
newerOf(it.cached, local.cachedBalance)
|
||||||
|
}
|
||||||
it.copy(
|
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(
|
state = State.Error(
|
||||||
cached = cached?.balance,
|
cached = mergedCache?.balance,
|
||||||
message = errorMessage(error),
|
message = errorMessage(error),
|
||||||
error = error
|
error = error
|
||||||
)
|
)
|
||||||
@ -119,12 +250,10 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 加载可用模型列表(优先缓存,force=true 时强制联网)。 */
|
||||||
* 加载可用模型列表(优先从缓存读取,首次才请求网络)。
|
|
||||||
* @param force true 时强制从网络刷新
|
|
||||||
*/
|
|
||||||
fun loadModels(force: Boolean = false) {
|
fun loadModels(force: Boolean = false) {
|
||||||
val service = _serviceType.value ?: return
|
val service = _serviceType.value ?: return
|
||||||
|
val serviceGen = serviceGeneration
|
||||||
if (!force && _models.value.isNotEmpty()) return
|
if (!force && _models.value.isNotEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_modelsLoading.value = true
|
_modelsLoading.value = true
|
||||||
@ -132,8 +261,12 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
ServiceType.CODEX -> refreshBalanceUseCase.fetchCodexModels()
|
ServiceType.CODEX -> refreshBalanceUseCase.fetchCodexModels()
|
||||||
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.fetchOpenCodeGoModels()
|
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.fetchOpenCodeGoModels()
|
||||||
ServiceType.OLLAMA -> refreshBalanceUseCase.fetchOllamaModels()
|
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
|
result
|
||||||
.onSuccess { list ->
|
.onSuccess { list ->
|
||||||
_models.value = list
|
_models.value = list
|
||||||
@ -160,18 +293,15 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户选择模型(持久化) */
|
|
||||||
fun selectModel(model: String) {
|
fun selectModel(model: String) {
|
||||||
_selectedModel.value = model
|
_selectedModel.value = model
|
||||||
_serviceType.value?.let { persistSelectedModel(it, model) }
|
_serviceType.value?.let { persistSelectedModel(it, model) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 一键激活用量。 */
|
||||||
* 一键激活用量:发送 API 请求触发用量统计。
|
|
||||||
* 支持 Codex / OpenCode Go / Ollama 三个服务。
|
|
||||||
*/
|
|
||||||
fun triggerUsage() {
|
fun triggerUsage() {
|
||||||
val service = _serviceType.value ?: return
|
val service = _serviceType.value ?: return
|
||||||
|
val serviceGen = serviceGeneration
|
||||||
val model = _selectedModel.value ?: run {
|
val model = _selectedModel.value ?: run {
|
||||||
_triggerState.value = TriggerState.Error("请先选择模型", null)
|
_triggerState.value = TriggerState.Error("请先选择模型", null)
|
||||||
return
|
return
|
||||||
@ -182,61 +312,79 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
ServiceType.CODEX -> refreshBalanceUseCase.triggerCodexUsage(model)
|
ServiceType.CODEX -> refreshBalanceUseCase.triggerCodexUsage(model)
|
||||||
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.triggerOpenCodeGoUsage(model)
|
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.triggerOpenCodeGoUsage(model)
|
||||||
ServiceType.OLLAMA -> refreshBalanceUseCase.triggerOllamaUsage(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
|
result
|
||||||
.onSuccess { responseBody ->
|
.onSuccess { responseBody ->
|
||||||
_triggerState.value = TriggerState.Success(responseBody)
|
_triggerState.value = TriggerState.Success(responseBody)
|
||||||
// 等待 2 秒让服务端处理用量,再刷新余额
|
|
||||||
kotlinx.coroutines.delay(2000)
|
kotlinx.coroutines.delay(2000)
|
||||||
|
if (serviceGen == serviceGeneration && _serviceType.value == service) {
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
val msg: String
|
val message: String
|
||||||
val respBody: String?
|
val responseBody: String?
|
||||||
when (error) {
|
when (error) {
|
||||||
is TriggerError -> {
|
is TriggerError -> {
|
||||||
msg = error.summary
|
message = error.summary
|
||||||
respBody = error.responseBody.ifBlank { null }
|
responseBody = error.responseBody.ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
is RepositoryError.InvalidCredential -> {
|
is RepositoryError.InvalidCredential -> {
|
||||||
msg = error.message ?: "凭据无效,请重新登录"
|
message = error.message ?: "凭据无效,请重新登录"
|
||||||
respBody = null
|
responseBody = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is RepositoryError.CredentialChanged -> {
|
||||||
|
message = "凭据已变更,请重新操作"
|
||||||
|
responseBody = null
|
||||||
|
}
|
||||||
|
|
||||||
is RepositoryError.Network -> {
|
is RepositoryError.Network -> {
|
||||||
msg = "网络异常"
|
message = "网络异常"
|
||||||
respBody = null
|
responseBody = null
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
msg = error.message ?: "未知错误"
|
message = error.message ?: "未知错误"
|
||||||
respBody = null
|
responseBody = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_triggerState.value = TriggerState.Error(msg, respBody)
|
_triggerState.value = TriggerState.Error(message, responseBody)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭响应弹窗,回到 Idle */
|
|
||||||
fun dismissTrigger() {
|
fun dismissTrigger() {
|
||||||
_triggerState.value = TriggerState.Idle
|
_triggerState.value = TriggerState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 手动输入模式:保存用户填的余额值。
|
|
||||||
*/
|
|
||||||
fun saveManualBalance(amount: Double) {
|
fun saveManualBalance(amount: Double) {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
|
val serviceGen = serviceGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
||||||
val balance = com.rainy.token.domain.model.ServiceBalance(
|
val balance = ServiceBalance(
|
||||||
service = type,
|
service = type,
|
||||||
amount = amount,
|
amount = amount,
|
||||||
unit = config.displayUnit,
|
unit = config.displayUnit,
|
||||||
isAvailable = true
|
isAvailable = true
|
||||||
)
|
)
|
||||||
balanceCache.put(type, balance)
|
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 {
|
viewModelScope.launch {
|
||||||
val credential = credentialRepository.get(type) ?: return@launch
|
val credential = credentialRepository.get(type) ?: return@launch
|
||||||
val updated = when (credential) {
|
val updated = when (credential) {
|
||||||
is com.rainy.token.domain.model.Credential.ApiKeyCredential ->
|
is Credential.ApiKeyCredential ->
|
||||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||||
is com.rainy.token.domain.model.Credential.SessionCredential ->
|
is Credential.SessionCredential ->
|
||||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||||
is com.rainy.token.domain.model.Credential.CodexCredential ->
|
is Credential.CodexCredential ->
|
||||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
credentialRepository.save(updated)
|
credentialRepository.save(updated)
|
||||||
@ -259,18 +407,31 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun loadFromCache() {
|
private fun loadFromCache() {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
|
val serviceGen = serviceGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val status = credentialRepository.statusFor(type)
|
val local = credentialRepository.readLocalState(type)
|
||||||
val cached = balanceCache.get(type)
|
val status = local.status
|
||||||
|
val cached = local.cachedBalance
|
||||||
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
||||||
val isManual = config.method == com.rainy.token.domain.service.FetchMethod.MANUAL
|
val isManual = config.method == com.rainy.token.domain.service.FetchMethod.MANUAL
|
||||||
val newState: State = when {
|
val newState: State = when {
|
||||||
isManual && cached != null -> State.Stale(cached.balance, cached.fetchedAt)
|
isManual && cached != null -> State.Stale(cached.balance, cached.fetchedAt)
|
||||||
isManual -> State.ManualModeHint
|
isManual -> State.ManualModeHint
|
||||||
cached != null && status.state == CredentialStatus.State.OK -> State.Stale(cached.balance, cached.fetchedAt)
|
status.state == CredentialStatus.State.NOT_CONFIGURED ->
|
||||||
cached != null -> State.Error(cached.balance, "凭据未配置或已过期", RepositoryError.InvalidCredential())
|
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
|
else -> State.Loading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch
|
||||||
|
currentCredentialFingerprint = local.fingerprint
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
hasCredential = status.state != CredentialStatus.State.NOT_CONFIGURED,
|
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) {
|
private fun errorMessage(error: Throwable): String = when (error) {
|
||||||
is RepositoryError.InvalidCredential -> "凭据无效,请在设置中重新配置"
|
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.Network -> "网络异常,请检查网络"
|
||||||
is RepositoryError.ServerError -> "服务端异常 (HTTP ${error.code})"
|
is RepositoryError.ServerError -> "服务端异常 (HTTP ${error.code})"
|
||||||
is RepositoryError.ParseError -> "数据解析失败: ${error.message}"
|
is RepositoryError.ParseError -> "数据解析失败: ${error.message}"
|
||||||
else -> error.message ?: "未知错误"
|
else -> error.message ?: "未知错误"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 模型持久化(Codex / OCGO / Ollama 各自独立 prefs) ──
|
|
||||||
|
|
||||||
private fun prefs(service: ServiceType): android.content.SharedPreferences =
|
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?) {
|
private fun persistSelectedModel(service: ServiceType, model: String?) {
|
||||||
prefs(service).edit().putString(KEY_SELECTED_MODEL, model).apply()
|
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>) {
|
private fun persistModelsCache(service: ServiceType, models: List<String>) {
|
||||||
prefs(service).edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply()
|
prefs(service).edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadModelsCache(service: ServiceType): List<String> =
|
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 {
|
sealed class State {
|
||||||
data object Loading : State()
|
data object Loading : State()
|
||||||
data class Fresh(val data: ServiceBalance) : State()
|
data class Fresh(val data: ServiceBalance) : State()
|
||||||
@ -324,7 +542,6 @@ sealed class State {
|
|||||||
val message: String,
|
val message: String,
|
||||||
val error: Throwable
|
val error: Throwable
|
||||||
) : State()
|
) : State()
|
||||||
/** 手动输入模式:尚未填入任何余额 */
|
|
||||||
data object ManualModeHint : State()
|
data object ManualModeHint : State()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,7 +551,6 @@ data class ServiceDetailUiState(
|
|||||||
val cached: CachedBalance? = null
|
val cached: CachedBalance? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Codex 一键激活用量状态 */
|
|
||||||
sealed class TriggerState {
|
sealed class TriggerState {
|
||||||
data object Idle : TriggerState()
|
data object Idle : TriggerState()
|
||||||
data object Loading : TriggerState()
|
data object Loading : TriggerState()
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
package com.rainy.token.ui.settings
|
package com.rainy.token.ui.settings
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rainy.token.data.repository.CommandCodeGoRepository
|
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
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.data.repository.RepositoryError
|
||||||
import com.rainy.token.domain.model.CookieEntry
|
import com.rainy.token.domain.model.CookieEntry
|
||||||
import com.rainy.token.domain.model.Credential
|
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.service.ServiceType
|
||||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@ -25,8 +23,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Provider
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 凭据编辑页 ViewModel。
|
* 凭据编辑页 ViewModel。
|
||||||
@ -38,10 +34,7 @@ import javax.inject.Provider
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CredentialEditViewModel @Inject constructor(
|
class CredentialEditViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>
|
||||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
|
||||||
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
|
|
||||||
private val ollamaRepositoryProvider: Provider<OllamaRepository>
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(CredentialEditUiState())
|
private val _uiState = MutableStateFlow(CredentialEditUiState())
|
||||||
@ -135,7 +128,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
lastVerifiedAt = 0L
|
lastVerifiedAt = 0L
|
||||||
)).copy(key = trimmedKey)
|
)).copy(key = trimmedKey)
|
||||||
credentialRepository.save(updated)
|
credentialRepository.save(updated)
|
||||||
// 同步回写 UI 状态(去掉前后空白后的版本),避免下次进来还看到带空格的旧值
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
apiKey = trimmedKey,
|
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(
|
private suspend fun testAndRollback(
|
||||||
type: ServiceType,
|
type: ServiceType,
|
||||||
saveAndPrep: suspend () -> Pair<Credential?, suspend () -> Result<com.rainy.token.domain.model.ServiceBalance>>,
|
saveAndPrep: suspend () -> Pair<Credential?, suspend () -> Result<com.rainy.token.domain.model.ServiceBalance>>,
|
||||||
@ -165,24 +146,37 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
rollbackOnFailure: Boolean
|
rollbackOnFailure: Boolean
|
||||||
) {
|
) {
|
||||||
val (previous, testBlock) = saveAndPrep()
|
val (previous, testBlock) = saveAndPrep()
|
||||||
|
val testedSnapshot = credentialRepository.snapshot(type)
|
||||||
val result = testBlock()
|
val result = testBlock()
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
val bal = result.getOrNull()
|
val bal = result.getOrNull()
|
||||||
_uiState.update { it.copy(message = formatSuccess(bal!!), hasExisting = true) }
|
_uiState.update { it.copy(message = formatSuccess(bal!!), hasExisting = true) }
|
||||||
} else {
|
} else {
|
||||||
if (rollbackOnFailure) {
|
val rolledBack = if (rollbackOnFailure && testedSnapshot != null) {
|
||||||
if (previous != null) credentialRepository.save(previous) else credentialRepository.remove(type)
|
credentialRepository.restoreIfCurrent(testedSnapshot, previous)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
val err = result.exceptionOrNull()
|
val err = result.exceptionOrNull()
|
||||||
val reason = when (err) {
|
val reason = when (err) {
|
||||||
is RepositoryError.InvalidCredential -> "服务拒绝该凭据 (401/403)"
|
is RepositoryError.InvalidCredential -> "服务拒绝该凭据 (401/403)"
|
||||||
|
is RepositoryError.CredentialChanged -> "测试期间凭据已变更"
|
||||||
is RepositoryError.RateLimited -> "请求过于频繁 (429)"
|
is RepositoryError.RateLimited -> "请求过于频繁 (429)"
|
||||||
is RepositoryError.ServerError -> "服务端错误 (${err.code})"
|
is RepositoryError.ServerError -> "服务端错误 (${err.code})"
|
||||||
is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}"
|
is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}"
|
||||||
else -> err?.message ?: "未知错误"
|
else -> err?.message ?: "未知错误"
|
||||||
}
|
}
|
||||||
|
val hasExisting = credentialRepository.get(type) != null
|
||||||
|
val rollbackNote = when {
|
||||||
|
!rollbackOnFailure -> ""
|
||||||
|
rolledBack -> ",已恢复原凭据"
|
||||||
|
else -> ",检测到凭据已变化,未执行回滚"
|
||||||
|
}
|
||||||
_uiState.update {
|
_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 {
|
viewModelScope.launch {
|
||||||
val existing = credentialRepository.get(type) as? Credential.ApiKeyCredential
|
val existing = credentialRepository.get(type) as? Credential.ApiKeyCredential
|
||||||
val updated = (existing ?: Credential.ApiKeyCredential(
|
val updated = (existing ?: Credential.ApiKeyCredential(
|
||||||
service = type, key = trimmedKey, lastVerifiedAt = 0L
|
service = type,
|
||||||
|
key = trimmedKey,
|
||||||
|
lastVerifiedAt = 0L
|
||||||
)).copy(key = trimmedKey)
|
)).copy(key = trimmedKey)
|
||||||
credentialRepository.save(updated)
|
credentialRepository.save(updated)
|
||||||
_uiState.update { it.copy(apiKey = trimmedKey) }
|
_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 {
|
private fun maskedKeyPreview(key: String): String {
|
||||||
if (key.length <= 8) return "*** (长度 ${key.length}) ***"
|
if (key.length <= 8) return "*** (长度 ${key.length}) ***"
|
||||||
val head = key.take(4)
|
val head = key.take(4)
|
||||||
@ -223,9 +219,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
return "$head***$tail (长度 ${key.length})"
|
return "$head***$tail (长度 ${key.length})"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存 OpenCode Go 的 cookie + workspaceId。
|
|
||||||
*/
|
|
||||||
fun saveOpenCodeGoSession() {
|
fun saveOpenCodeGoSession() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -239,9 +232,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存并立即测试连接。测试失败则回滚凭据。
|
|
||||||
*/
|
|
||||||
fun testAndSaveOpenCodeGo() {
|
fun testAndSaveOpenCodeGo() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -254,7 +244,7 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim())
|
doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim())
|
||||||
testAndRollback(
|
testAndRollback(
|
||||||
type = type,
|
type = type,
|
||||||
saveAndPrep = { previous to { openCodeGoRepositoryProvider.get().fetchBalance() } },
|
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
|
||||||
formatSuccess = { "连接成功,凭据已保存" },
|
formatSuccess = { "连接成功,凭据已保存" },
|
||||||
rollbackOnFailure = true
|
rollbackOnFailure = true
|
||||||
)
|
)
|
||||||
@ -298,7 +288,11 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
credentialRepository.save(updated)
|
credentialRepository.save(updated)
|
||||||
_uiState.update {
|
_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) }
|
_uiState.update { it.copy(codexAuthJson = value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存 Codex auth.json 完整内容,解析并存储为 CodexCredential。
|
|
||||||
* 支持两种格式:
|
|
||||||
* 1. 完整 {"tokens": {"access_token": "...", "refresh_token": "...", ...}}
|
|
||||||
* 2. 扁平 {"access_token": "...", "refresh_token": "...", ...}
|
|
||||||
*/
|
|
||||||
fun saveCodexAuthJson() {
|
fun saveCodexAuthJson() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -328,13 +316,11 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
|
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
|
||||||
val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content
|
val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content
|
||||||
val accountId = tokens["account_id"]?.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()
|
val expiresAt = tokens["expiresAt"]?.jsonPrimitive?.content?.toLongOrNull()
|
||||||
?: tokens["expires_at"]?.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
|
?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000
|
||||||
|
|
||||||
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
|
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
|
||||||
@ -351,17 +337,18 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
lastVerifiedAt = System.currentTimeMillis()
|
lastVerifiedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
credentialRepository.save(newCred)
|
credentialRepository.save(newCred)
|
||||||
_uiState.update { it.copy(message = "已保存 Codex 凭据,token 到期后会自动刷新", hasExisting = true) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
message = "已保存 Codex 凭据,token 到期后会自动刷新",
|
||||||
|
hasExisting = true
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update { it.copy(message = "解析失败:${e.message ?: "JSON 格式错误"}") }
|
_uiState.update { it.copy(message = "解析失败:${e.message ?: "JSON 格式错误"}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存 Codex auth.json 并立即测试连接。
|
|
||||||
* API Key 存 token 字段,cookie 字符串解析后存 cookies 列表。
|
|
||||||
*/
|
|
||||||
fun saveCommandCodeGoCredential() {
|
fun saveCommandCodeGoCredential() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -372,7 +359,9 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
val cookies = if (current.cookieInput.isNotBlank()) {
|
val cookies = if (current.cookieInput.isNotBlank()) {
|
||||||
parseCookieString(current.cookieInput)
|
parseCookieString(current.cookieInput)
|
||||||
} else emptyList()
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val existing = credentialRepository.get(type) as? Credential.SessionCredential
|
val existing = credentialRepository.get(type) as? Credential.SessionCredential
|
||||||
@ -397,9 +386,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存并测试 CommandCode Go 连通性。
|
|
||||||
*/
|
|
||||||
fun testAndSaveCommandCodeGo() {
|
fun testAndSaveCommandCodeGo() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -411,17 +397,27 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val cookies = if (current.cookieInput.isNotBlank()) {
|
val cookies = if (current.cookieInput.isNotBlank()) {
|
||||||
parseCookieString(current.cookieInput)
|
parseCookieString(current.cookieInput)
|
||||||
} else emptyList()
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
val existing = credentialRepository.get(type) as? Credential.SessionCredential
|
val existing = credentialRepository.get(type) as? Credential.SessionCredential
|
||||||
val updated = (existing ?: Credential.SessionCredential(
|
val updated = (existing ?: Credential.SessionCredential(
|
||||||
service = type, cookies = cookies, token = trimmedKey
|
service = type,
|
||||||
)).copy(cookies = cookies, token = trimmedKey, lastVerifiedAt = System.currentTimeMillis())
|
cookies = cookies,
|
||||||
|
token = trimmedKey
|
||||||
|
)).copy(
|
||||||
|
cookies = cookies,
|
||||||
|
token = trimmedKey,
|
||||||
|
lastVerifiedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
credentialRepository.save(updated)
|
credentialRepository.save(updated)
|
||||||
_uiState.update { it.copy(apiKey = trimmedKey) }
|
_uiState.update { it.copy(apiKey = trimmedKey) }
|
||||||
testAndRollback(
|
testAndRollback(
|
||||||
type = type,
|
type = type,
|
||||||
saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(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
|
rollbackOnFailure = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -435,10 +431,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(triggerApiKey = value) }
|
_uiState.update { it.copy(triggerApiKey = value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存触发用量 API Key(OCGO / Ollama 共用)。
|
|
||||||
* 合并保存到已有的 SessionCredential 中。
|
|
||||||
*/
|
|
||||||
fun saveTriggerApiKey() {
|
fun saveTriggerApiKey() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -463,9 +455,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存 Ollama Pro 的 Cookie 字符串。
|
|
||||||
*/
|
|
||||||
fun saveOllamaCredential() {
|
fun saveOllamaCredential() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -479,9 +468,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存并立即测试 Ollama Cloud 连接。
|
|
||||||
*/
|
|
||||||
fun testAndSaveOllama() {
|
fun testAndSaveOllama() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -494,8 +480,10 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
doSaveOllama(current.ollamaCookie.trim())
|
doSaveOllama(current.ollamaCookie.trim())
|
||||||
testAndRollback(
|
testAndRollback(
|
||||||
type = type,
|
type = type,
|
||||||
saveAndPrep = { previous to { ollamaRepositoryProvider.get().fetchBalance() } },
|
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
|
||||||
formatSuccess = { bal -> "连接成功!Session: ${bal.amount}% · ${bal.extras["plan"] ?: "—"}" },
|
formatSuccess = { bal ->
|
||||||
|
"连接成功!Session: ${bal.amount}% · ${bal.extras["plan"] ?: "—"}"
|
||||||
|
},
|
||||||
rollbackOnFailure = true
|
rollbackOnFailure = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -554,10 +542,14 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
ws != null && auth != null -> _uiState.update { it.copy(message = "已识别 workspaceId 和 auth cookie") }
|
ws != null && auth != null ->
|
||||||
ws != null -> _uiState.update { it.copy(message = "已识别 workspaceId,请再粘贴 auth cookie") }
|
_uiState.update { it.copy(message = "已识别 workspaceId 和 auth cookie") }
|
||||||
auth != null -> _uiState.update { it.copy(message = "已识别 auth cookie,请再粘贴 workspaceId") }
|
ws != null ->
|
||||||
else -> _uiState.update { it.copy(message = "未识别到有效凭据") }
|
_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(";")
|
return cookieString.split(";")
|
||||||
.mapNotNull { entry ->
|
.mapNotNull { entry ->
|
||||||
val parts = entry.trim().split("=", limit = 2)
|
val parts = entry.trim().split("=", limit = 2)
|
||||||
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) return@mapNotNull null
|
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
|
||||||
// cookie 值原样存储,不做任何解码(better-auth 签名包含了原始字符)
|
return@mapNotNull null
|
||||||
|
}
|
||||||
CookieEntry(name = parts[0].trim(), value = parts[1].trim())
|
CookieEntry(name = parts[0].trim(), value = parts[1].trim())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
@ -64,6 +66,11 @@ fun SettingsScreen(
|
|||||||
onOpenDebugLog: () -> Unit = {},
|
onOpenDebugLog: () -> Unit = {},
|
||||||
viewModel: SettingsViewModel = hiltViewModel()
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
|
// 重新进入设置页时刷新凭据状态(从凭据编辑页保存/删除后返回)
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
viewModel.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
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