diff --git a/AGENTS.md b/AGENTS.md index 9a67119..550bd22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」( - ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证 - ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage` - ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`) +- ✅ Codex / ChatGPT Plus — 粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新 - ❌ OpenCode Zen / 小米 MiMo — 未实现 **用量统计系统**: @@ -131,6 +132,7 @@ export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64 DashboardViewModel.refresh() → RefreshBalanceUseCase(service) → DeepSeekRepository.fetchBalance() / OpenCodeGoRepository.fetchBalance() + → CommandCodeGoRepository.fetchBalance() / CodexRepository.fetchBalance() → BalanceCache.put(service, result) → OpenCodeGoWidgetProvider.notifyDataChanged(context) diff --git a/README.md b/README.md index 579c4e2..f2b40d3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go | 特性 | 说明 | |------|------| -| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 · 平板自适应双窗格布局 | +| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ Codex / ChatGPT Plus 额度(5h/Weekly)+ 下拉全局刷新 · 平板自适应双窗格布局 | | 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月);平板并排展示 | | 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式) | | 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 | @@ -47,7 +47,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go 前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。 -> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据或 CommandCode Go API Key 才能拉取数据。 +> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key 或 Codex auth.json 才能拉取数据。 --- @@ -76,8 +76,8 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go │ │ │ │ ┌────────────────────▼─────────────────────────┐ │ │ │ Repository + Network │ │ -│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取 │ │ -│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository │ │ +│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取 │ +│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository · CodexRepository(OkHttp 调 chatgpt.com)│ │ │ └────────────────────┬────────────────────────┘ │ │ │ │ │ ┌────────────────────▼─────────────────────────┐ │ @@ -100,7 +100,7 @@ Rainytoken/ │ │ ├── cache/ # BalanceCache(DataStore) │ │ ├── local/ # UsageCache(DataStore + 内存缓存)、UsageRecord、ChartBucket │ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + UsageRepository -│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Credential Repository +│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Codex / Credential Repository │ ├── domain/ │ │ ├── model/ # ServiceBalance、Credential 等 │ │ ├── service/ # ServiceType 枚举 diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt new file mode 100644 index 0000000..9ff0608 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -0,0 +1,147 @@ +package com.rainy.token.data.repository + +import com.rainy.token.data.cache.BalanceCache +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.service.ServiceConfigProvider +import com.rainy.token.domain.service.ServiceType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import javax.inject.Singleton + +@Singleton +class CodexRepository( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + private val balanceCache: BalanceCache +) { + private val json = Json { ignoreUnknownKeys = true } + + companion object { + private const val WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage" + private const val OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" + private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" + private const val REFRESH_BUFFER_MS = 60L * 60 * 1000 + } + + suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.CODEX) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + if (credential !is Credential.CodexCredential) + return@withContext Result.failure(RepositoryError.InvalidCredential()) + + val effectiveCred = if (tokenNeedsRefresh(credential)) { + val refreshed = refreshToken(credential) + if (refreshed != null) { credentialRepository.save(refreshed); refreshed } else credential + } else credential + + val usageResult = try { + fetchJson(WHAM_USAGE, effectiveCred.accessToken) + } catch (e: IOException) { + return@withContext Result.failure(RepositoryError.Network(e)) + } catch (e: RepositoryError) { + if (e is RepositoryError.InvalidCredential && effectiveCred == credential) { + val retry = refreshToken(credential) + if (retry != null) { + credentialRepository.save(retry) + try { fetchJson(WHAM_USAGE, retry.accessToken) } + catch (e2: RepositoryError) { return@withContext Result.failure(e2) } + catch (e2: IOException) { return@withContext Result.failure(RepositoryError.Network(e2)) } + catch (e2: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e2)) } + } else return@withContext Result.failure(e) + } else return@withContext Result.failure(e) + } catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) } + + val windows = parseUsageWindows(usageResult) + if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据")) + + val config = ServiceConfigProvider.get(ServiceType.CODEX) + val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first() + val extras = buildMap { + windows.forEachIndexed { i, w -> + put("window_$i.label", w.label) + put("window_$i.remainingPct", w.remainingPct.toString()) + put("window_$i.resetAt", w.resetAt?.toString() ?: "") + } + usageResult["plan_type"]?.jsonPrimitive?.content?.let { put("plan", it) } + usageResult["credits"]?.jsonObject?.let { c -> + c["balance"]?.jsonPrimitive?.floatOrNull?.let { put("usageCredits", it.toString()) } + } + } + val balance = ServiceBalance(ServiceType.CODEX, primary.remainingPct.toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras) + balanceCache.put(ServiceType.CODEX, balance) + credentialRepository.save(effectiveCred.copy(lastVerifiedAt = System.currentTimeMillis())) + Result.success(balance) + } + + private fun tokenNeedsRefresh(cred: Credential.CodexCredential): Boolean = + System.currentTimeMillis() >= cred.expiresAt - REFRESH_BUFFER_MS + + private fun refreshToken(cred: Credential.CodexCredential): Credential.CodexCredential? { + val bodyStr = json.encodeToString(OAuthRefreshRequest.serializer(), + OAuthRefreshRequest("refresh_token", cred.refreshToken, CLIENT_ID, "openid profile email")) + val request = Request.Builder().url(OAUTH_TOKEN_URL) + .header("Content-Type", "application/json") + .post(bodyStr.toRequestBody("application/json".toMediaType())).build() + return try { + okHttpClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return@use null + val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), resp.body?.string() ?: return@use null) + cred.copy(accessToken = tr.accessToken, refreshToken = tr.refreshToken, + expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L, lastVerifiedAt = System.currentTimeMillis()) + } + } catch (e: Exception) { null } + } + + private fun fetchJson(url: String, token: String): JsonObject { + val request = Request.Builder().url(url) + .header("Accept", "application/json") + .header("OpenAI-Beta", "codex-1") + .header("OAI-Language", "en") + .header("User-Agent", "codex-reset-tracker/0.1") + .header("originator", "codex-reset-tracker") + .header("Authorization", "Bearer $token").get().build() + val resp = okHttpClient.newCall(request).execute() + resp.use { + if (!it.isSuccessful) throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential() else RepositoryError.ServerError(it.code) + return json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")).jsonObject + } + } + + private data class UsageWindow(val label: String, val remainingPct: Int, val resetAt: Long?) + + private fun parseUsageWindows(data: JsonObject): List { + val result = mutableListOf() + fun addWindows(rl: JsonObject?) { + if (rl == null) return + for (key in listOf("primary_window", "secondary_window")) { + val w = rl[key]?.jsonObject ?: continue + val usedPct = w["used_percent"]?.jsonPrimitive?.floatOrNull ?: continue + val remaining = (100 - usedPct).toInt().coerceIn(0, 100) + result.add(UsageWindow(durationLabel(w["limit_window_seconds"]?.jsonPrimitive?.longOrNull), remaining, w["reset_at"]?.jsonPrimitive?.longOrNull?.times(1000L))) + } + } + addWindows(data["rate_limit"]?.jsonObject) + (data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item -> + if (item is JsonObject) addWindows(item["rate_limit"]?.jsonObject) + } + return result + } + + private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "Weekly"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" } + + @Serializable data class OAuthRefreshRequest(@kotlinx.serialization.SerialName("grant_type") val grantType: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("client_id") val clientId: String, val scope: String) + @Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null) +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index fa29abd..895cbe8 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -9,6 +9,7 @@ import com.rainy.token.data.local.UsageCache import com.rainy.token.data.local.usageCacheDataStore import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.DeepSeekRepository +import com.rainy.token.data.repository.CodexRepository import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.data.repository.OpenCodeGoRepository @@ -136,6 +137,17 @@ object NetworkModule { balanceCache: BalanceCache ): DeepSeekRepository = DeepSeekRepository(deepSeekApi, credentialRepository, balanceCache) + /** + * Codex / ChatGPT Plus 额度仓库:用 OkHttp 调 chatgpt.com 后台 API。 + */ + @Provides + @Singleton + fun provideCodexRepository( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository, + balanceCache: BalanceCache + ): CodexRepository = CodexRepository(okHttpClient, credentialRepository, balanceCache) + /** 余额缓存 DataStore(计划 7.1) */ @Provides @Singleton diff --git a/app/src/main/java/com/rainy/token/domain/model/Credential.kt b/app/src/main/java/com/rainy/token/domain/model/Credential.kt index 1378a28..97e91df 100644 --- a/app/src/main/java/com/rainy/token/domain/model/Credential.kt +++ b/app/src/main/java/com/rainy/token/domain/model/Credential.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.Serializable * * - [ApiKeyCredential] 用于 DeepSeek(API Key 形式) * - [SessionCredential] 用于 OpenCode Zen/Go(用户登录 dashboard 后粘贴的值) + * - [CodexCredential] 用于 Codex / ChatGPT Plus(完整 OAuth 凭据,含自动刷新) */ @Serializable sealed class Credential { @@ -42,6 +43,21 @@ sealed class Credential { val workspaceId: String? = null, override val lastVerifiedAt: Long = 0L ) : Credential() + + /** + * 用于 Codex / ChatGPT Plus:完整 OAuth 凭据,支持自动刷新。 + * 用户从 auth.json 粘贴整个 tokens 对象。 + */ + @Serializable + data class CodexCredential( + override val service: ServiceType, + val accessToken: String, + val refreshToken: String, + val accountId: String, + /** 过期时间(epoch millis) */ + val expiresAt: Long, + override val lastVerifiedAt: Long = 0L + ) : Credential() } /** diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt index 684f1e2..fd45261 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt @@ -54,6 +54,12 @@ object ServiceConfigProvider { method = FetchMethod.REST_API, loginUrl = "", // API Key 模式不需要登录 URL displayUnit = "$" + ), + ServiceType.CODEX to ServiceConfig( + type = ServiceType.CODEX, + method = FetchMethod.REST_API, + loginUrl = "", + displayUnit = "%" ) ) diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt index 72b8981..af5f1a9 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt @@ -16,7 +16,8 @@ enum class ServiceType( ) { OPENCODE_GO("OpenCode Go", "opencode_go"), COMMANDCODE_GO("CommandCode Go", "commandcode_go"), - DEEPSEEK("DeepSeek", "deepseek"); + DEEPSEEK("DeepSeek", "deepseek"), + CODEX("Codex / ChatGPT", "codex"); companion object { fun fromStorageKey(key: String): ServiceType? = diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index fb2a8da..663ff97 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -1,5 +1,6 @@ package com.rainy.token.domain.usecase +import com.rainy.token.data.repository.CodexRepository import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.DeepSeekRepository import com.rainy.token.data.repository.OpenCodeGoRepository @@ -20,11 +21,13 @@ import javax.inject.Provider class RefreshBalanceUseCase @Inject constructor( private val deepSeekRepositoryProvider: Provider, private val openCodeGoRepositoryProvider: Provider, - private val commandCodeGoRepositoryProvider: Provider + private val commandCodeGoRepositoryProvider: Provider, + private val codexRepositoryProvider: Provider ) { suspend operator fun invoke(service: ServiceType): Result = when (service) { ServiceType.DEEPSEEK -> deepSeekRepositoryProvider.get().fetchBalance() ServiceType.OPENCODE_GO -> openCodeGoRepositoryProvider.get().fetchBalance() ServiceType.COMMANDCODE_GO -> commandCodeGoRepositoryProvider.get().fetchBalance() + ServiceType.CODEX -> codexRepositoryProvider.get().fetchBalance() } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt index b6e0d5e..427944e 100644 --- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt +++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt @@ -81,5 +81,25 @@ fun ServiceIcon( ) } } + ServiceType.CODEX -> { + // ChatGPT 官方 favicon logo + Box( + modifier = modifier + .size(size.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isSystemInDarkTheme()) Color(0xFF2D3748) else Color(0xFF10A37F)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_codex_logo), + contentDescription = "Codex / ChatGPT", + modifier = Modifier + .size((size * 0.6).dp), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( + if (isSystemInDarkTheme()) Color.White else Color.White + ) + ) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 5dd71f4..4fee470 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -364,6 +364,12 @@ private fun BalanceMainArea(card: DashboardCardUi) { Spacer(modifier = Modifier.height(12.dp)) CommandCodeGoUsageWindows(balance) } + card.service == ServiceType.CODEX -> { + // Codex / ChatGPT Plus 卡片:显示用量窗口(5h / Weekly) + CodexMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + CodexUsageWindows(balance) + } else -> { // DeepSeek 等:主数字 + 单位 Row(verticalAlignment = Alignment.Bottom) { @@ -502,6 +508,75 @@ private fun CommandCodeGoUsageWindows(balance: com.rainy.token.domain.model.Serv } } +@Composable +private fun CodexMainBalance(balance: com.rainy.token.domain.model.ServiceBalance) { + val plan = balance.extras["plan"]?.let { + when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } } + } ?: "—" + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "%", + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "5h 剩余", + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "· $plan", + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } +} + +@Composable +private fun CodexUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { + val extras = balance.extras + // 从 extras 里动态提取窗口数据,数量不固定(通常 2 个:5h + Weekly) + val windowCount = extras.keys + .mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() } + .distinct() + .maxOrNull()?.plus(1) ?: 0 + + val windows = (0 until windowCount).map { i -> + val label = extras["window_$i.label"] ?: "Usage" + val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() + val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } + Triple(label, remainingPct, resetAt) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (windows.isEmpty()) { + CompactUsageRowEmpty(label = "Usage", resetInSec = null) + } else { + windows.forEach { (label, remainingPct, resetAt) -> + if (remainingPct != null) { + // remainingPct 是剩余百分比,CompactUsageRow 需要的是已用百分比 + val usedPct = (100 - remainingPct).coerceIn(0, 100) + CompactUsageRow(label = label, pct = usedPct, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + } else { + CompactUsageRowEmpty(label = label, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + } + } + } + } +} + @Composable private fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { Column { @@ -626,6 +701,7 @@ private fun secondaryLine(card: DashboardCardUi): String = when (card.service) { ServiceType.DEEPSEEK -> "REST API · ¥" ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额" ServiceType.COMMANDCODE_GO -> "JSON API · \$" + ServiceType.CODEX -> "ChatGPT Plus · Codex 额度" } private fun footerText(card: DashboardCardUi): String { diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index fa23457..fe10c96 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -134,6 +134,9 @@ fun ServiceDetailScreen( ServiceType.COMMANDCODE_GO -> { item { CommandCodeGoUsageCard(uiState.state) } } + ServiceType.CODEX -> { + // Codex 详情:暂无专用详情卡片,用通用余额展示即可 + } } // 错误信息(如有) @@ -647,6 +650,7 @@ private fun mainCardLabel(service: ServiceType): String = when (service) { ServiceType.DEEPSEEK -> "当前余额" ServiceType.OPENCODE_GO -> "5h 实时用量" ServiceType.COMMANDCODE_GO -> "月度余额" + ServiceType.CODEX -> "5h 剩余额度" } private fun stateToChip(state: State): StatusStyle = when (state) { diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index ece0c47..ba2b37b 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -106,6 +106,8 @@ class ServiceDetailViewModel @Inject constructor( credential.copy(lastVerifiedAt = System.currentTimeMillis()) is com.rainy.token.domain.model.Credential.SessionCredential -> credential.copy(lastVerifiedAt = System.currentTimeMillis()) + is com.rainy.token.domain.model.Credential.CodexCredential -> + credential.copy(lastVerifiedAt = System.currentTimeMillis()) } credentialRepository.save(updated) loadFromCache() diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 4388066..9a7468a 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -71,6 +71,7 @@ fun CredentialEditScreen( var showCookieHelp by remember { mutableStateOf(false) } var showGoHelp by remember { mutableStateOf(false) } var showOpenCcgoHelp by remember { mutableStateOf(false) } + var showCodexHelp by remember { mutableStateOf(false) } LaunchedEffect(uiState.message) { uiState.message?.let { msg -> @@ -121,6 +122,14 @@ fun CredentialEditScreen( onTestAndSave = viewModel::testAndSaveCommandCodeGo, onShowHelp = { showOpenCcgoHelp = true } ) + } else if (service == ServiceType.CODEX) { + CodexAuthJsonForm( + authJson = uiState.codexAuthJson, + hasExisting = uiState.hasExisting, + onAuthJsonChange = viewModel::updateCodexAuthJson, + onSave = viewModel::saveCodexAuthJson, + onShowHelp = { showCodexHelp = true } + ) } else { ApiKeyForm( apiKey = uiState.apiKey, @@ -252,6 +261,40 @@ fun CredentialEditScreen( } ) } + + if (showCodexHelp) { + AlertDialog( + onDismissRequest = { showCodexHelp = false }, + title = { Text("如何获取 Codex auth.json?") }, + text = { + Column { + Text("1. 在终端运行 codex-oauth-proxy 或") + Text(" codex-proxy,完成 ChatGPT 登录") + Text("") + Text("2. 凭证文件保存在:") + Text(" ~/.config/codex-oauth-proxy/auth.json") + Text("") + Text("3. 用文本编辑器打开该文件,") + Text(" 复制全部内容粘贴到上方输入框") + Text("") + Text(" JSON 格式示例:") + Text(" {\"tokens\": {") + Text(" \"access_token\": \"...\",") + Text(" \"refresh_token\": \"...\",") + Text(" \"expiresAt\": 1783727108252") + Text(" }}") + Text("") + Text("提示:导入后 APP 会自动刷新 token,") + Text("不需要手动更新。") + } + }, + confirmButton = { + TextButton(onClick = { showCodexHelp = false }) { + Text("我知道了") + } + } + ) + } } @Composable @@ -411,6 +454,36 @@ Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { } } +@Composable +private fun CodexAuthJsonForm( + authJson: String, + hasExisting: Boolean, + onAuthJsonChange: (String) -> Unit, + onSave: () -> Unit, + onShowHelp: () -> Unit +) { + Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium) + Text( + text = "从 codex-oauth-proxy 的 auth.json 复制完整内容粘贴到下方。APP 会自动解析并支持自动刷新。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + OutlinedTextField( + value = authJson, + onValueChange = onAuthJsonChange, + label = { Text("auth.json 完整内容") }, + placeholder = { Text("粘贴 {\"tokens\": {...}} 完整 JSON") }, + modifier = Modifier.fillMaxWidth(), + minLines = 6 + ) + Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { + Text(if (hasExisting) "更新凭据" else "导入并保存") + } + OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { + Text(text = "如何获取 auth.json?") + } +} + @Composable private fun ManualCookieForm( cookieValue: String, diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 95944ac..cee77da 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -21,6 +21,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import javax.inject.Inject import javax.inject.Provider @@ -131,7 +134,7 @@ class CredentialEditViewModel @Inject constructor( */ fun testAndSaveApiKey() { val type = serviceType ?: return - if (type != ServiceType.DEEPSEEK && type != ServiceType.COMMANDCODE_GO) { + if (type != ServiceType.DEEPSEEK && type != ServiceType.COMMANDCODE_GO && type != ServiceType.CODEX) { _uiState.update { it.copy(message = "暂不支持测试此服务") } return } @@ -166,9 +169,9 @@ class CredentialEditViewModel @Inject constructor( // 在错误信息里露出 Key 的首尾字符,便于区分"带空格"还是"Key 真的失效" val preview = maskedKeyPreview(trimmedKey) val reason = when (err) { - is RepositoryError.InvalidCredential -> "DeepSeek 拒绝该 Key (401/403),请检查是否完整复制(当前: '$preview')" + is RepositoryError.InvalidCredential -> "服务拒绝该凭据 (401/403),请检查是否完整复制(当前: '$preview')" is RepositoryError.RateLimited -> "请求过于频繁 (429),稍后再试" - is RepositoryError.ServerError -> "DeepSeek 服务端错误 (${err.code}),凭据本身没问题" + is RepositoryError.ServerError -> "服务端错误 (${err.code}),凭据本身没问题" is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}" else -> "未知错误:${err.message ?: err::class.simpleName}" } @@ -272,8 +275,58 @@ class CredentialEditViewModel @Inject constructor( } } + fun updateCodexAuthJson(value: String) { + _uiState.update { it.copy(codexAuthJson = value) } + } + /** - * 保存 CommandCode Go 的 API Key + session cookie。 + * 保存 Codex auth.json 完整内容,解析并存储为 CodexCredential。 + * 支持两种格式: + * 1. 完整 {"tokens": {"access_token": "...", "refresh_token": "...", ...}} + * 2. 扁平 {"access_token": "...", "refresh_token": "...", ...} + */ + fun saveCodexAuthJson() { + val type = serviceType ?: return + val current = _uiState.value + val text = current.codexAuthJson.trim() + if (text.isBlank()) { + _uiState.update { it.copy(message = "请粘贴 auth.json 内容") } + return + } + viewModelScope.launch { + try { + val parsed = Json.parseToJsonElement(text).jsonObject + val tokens = parsed["tokens"]?.jsonObject ?: parsed + val accessToken = tokens["access_token"]?.jsonPrimitive?.content + val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content + val accountId = tokens["account_id"]?.jsonPrimitive?.content ?: "" + val expiresAt = tokens["expiresAt"]?.jsonPrimitive?.content?.toLongOrNull() + ?: tokens["expires_at"]?.jsonPrimitive?.content?.toLongOrNull() + ?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000 + + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + _uiState.update { it.copy(message = "auth.json 缺少 access_token 或 refresh_token") } + return@launch + } + + val newCred = Credential.CodexCredential( + service = ServiceType.CODEX, + accessToken = accessToken, + refreshToken = refreshToken, + accountId = accountId, + expiresAt = expiresAt, + lastVerifiedAt = System.currentTimeMillis() + ) + credentialRepository.save(newCred) + _uiState.update { it.copy(message = "已保存 Codex 凭据,token 到期后会自动刷新", hasExisting = true) } + } catch (e: Exception) { + _uiState.update { it.copy(message = "解析失败:${e.message ?: "JSON 格式错误"}") } + } + } + } + + /** + * 保存 Codex auth.json 并立即测试连接。 * API Key 存 token 字段,cookie 字符串解析后存 cookies 列表。 */ fun saveCommandCodeGoCredential() { @@ -441,5 +494,6 @@ data class CredentialEditUiState( val workspaceId: String = "", val cookieCount: Int = 0, val hasExisting: Boolean = false, + val codexAuthJson: String = "", val message: String? = null ) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_codex_logo.xml b/app/src/main/res/drawable/ic_codex_logo.xml new file mode 100644 index 0000000..eb5fa60 --- /dev/null +++ b/app/src/main/res/drawable/ic_codex_logo.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file