feat: add Codex/ChatGPT Plus service with auth.json import and auto token refresh
- New ServiceType.CODEX with CodexCredential (access_token + refresh_token + expiresAt) - CodexRepository: query chatgpt.com/backend-api/wham/usage, auto refresh token 60min before expiry - Import full auth.json in settings, supports automatic OAuth token refresh - ChatGPT favicon SVG icon (ic_codex_logo.xml) - Dashboard card shows 5h/Weekly usage windows with reset times - Update AGENTS.md and README.md with Codex service info
This commit is contained in:
parent
1d11aa8ed2
commit
4cbf593983
@ -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)
|
||||
|
||||
|
||||
10
README.md
10
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 枚举
|
||||
|
||||
@ -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<ServiceBalance> = 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<UsageWindow> {
|
||||
val result = mutableListOf<UsageWindow>()
|
||||
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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 = "%"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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? =
|
||||
|
||||
@ -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<DeepSeekRepository>,
|
||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>
|
||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
||||
private val codexRepositoryProvider: Provider<CodexRepository>
|
||||
) {
|
||||
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> = when (service) {
|
||||
ServiceType.DEEPSEEK -> deepSeekRepositoryProvider.get().fetchBalance()
|
||||
ServiceType.OPENCODE_GO -> openCodeGoRepositoryProvider.get().fetchBalance()
|
||||
ServiceType.COMMANDCODE_GO -> commandCodeGoRepositoryProvider.get().fetchBalance()
|
||||
ServiceType.CODEX -> codexRepositoryProvider.get().fetchBalance()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
10
app/src/main/res/drawable/ic_codex_logo.xml
Normal file
10
app/src/main/res/drawable/ic_codex_logo.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="180dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="180"
|
||||
android:viewportHeight="180">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M101.228 164.247C96.2776 164.247 91.5751 163.307 87.1201 161.426C82.6651 159.545 78.7051 156.921 75.2401 153.555C71.4781 154.842 67.5676 155.486 63.5086 155.486C56.8756 155.486 50.7376 153.852 45.0946 150.585C39.4516 147.318 34.8976 142.863 31.4326 137.22C28.0666 131.577 26.3836 125.291 26.3836 118.361C26.3836 115.49 26.7796 112.371 27.5716 109.005C23.6116 105.342 20.5426 101.135 18.3646 96.3828C16.1866 91.5318 15.0976 86.4828 15.0976 81.2358C15.0976 75.8898 16.2361 70.7418 18.5131 65.7918C20.7901 60.8418 23.9581 56.5848 28.0171 53.0208C32.1751 49.3578 36.9766 46.8333 42.4216 45.4473C43.5106 39.8043 45.7876 34.7553 49.2526 30.3003C52.8166 25.7463 57.1726 22.1823 62.3206 19.6083C67.4686 17.0343 72.9631 15.7473 78.8041 15.7473C83.7541 15.7473 88.4566 16.6878 92.9116 18.5688C97.3666 20.4498 101.327 23.0733 104.792 26.4393C108.554 25.1523 112.464 24.5088 116.523 24.5088C123.156 24.5088 129.294 26.1423 134.937 29.4093C140.58 32.6763 145.085 37.1313 148.451 42.7743C151.916 48.4173 153.648 54.7038 153.648 61.6338C153.648 64.5048 153.252 67.6233 152.46 70.9893C156.42 74.6523 159.489 78.9093 161.667 83.7603C163.845 88.5123 164.934 93.5118 164.934 98.7588C164.934 104.105 163.796 109.253 161.519 114.203C159.242 119.153 156.024 123.459 151.866 127.122C147.807 130.686 143.055 133.161 137.61 134.547C136.521 140.19 134.195 145.239 130.631 149.694C127.166 154.248 122.859 157.812 117.711 160.386C112.563 162.96 107.069 164.247 101.228 164.247ZM64.5481 145.685C69.4981 145.685 73.8046 144.645 77.4676 142.566L105.386 126.528C106.376 125.835 106.871 124.895 106.871 123.707V110.936L70.9336 131.577C68.7556 132.864 66.5776 132.864 64.3996 131.577L36.3331 115.391C36.3331 115.688 36.2836 116.034 36.1846 116.43C36.1846 116.826 36.1846 117.42 36.1846 118.212C36.1846 123.261 37.3726 127.914 39.7486 132.171C42.2236 136.329 45.6391 139.596 49.9951 141.972C54.3511 144.447 59.2021 145.685 64.5481 145.685ZM66.0331 121.479C66.6271 121.776 67.1716 121.925 67.6666 121.925C68.1616 121.925 68.6566 121.776 69.1516 121.479L80.2891 115.094L44.5006 94.3038C42.3226 93.0168 41.2336 91.0863 41.2336 88.5123V56.2878C36.2836 58.4658 32.3236 61.8318 29.3536 66.3858C26.3836 70.8408 24.8986 75.7908 24.8986 81.2358C24.8986 86.0868 26.1361 90.7398 28.6111 95.1948C31.0861 99.6498 34.3036 103.016 38.2636 105.293L66.0331 121.479ZM101.228 154.446C106.475 154.446 111.227 153.258 115.484 150.882C119.741 148.506 123.107 145.239 125.582 141.081C128.057 136.923 129.294 132.27 129.294 127.122V95.0463C129.294 93.8583 128.799 92.9673 127.809 92.3733L116.523 85.8393V127.271C116.523 129.845 115.434 131.775 113.256 133.062L85.1896 149.249C90.0406 152.714 95.3866 154.446 101.228 154.446ZM106.871 100.095V79.8993L90.09 70.3953L73.1611 79.8993V100.095L90.09 109.599L106.871 100.095ZM63.5086 52.7238C63.5086 50.1498 64.5976 48.2193 66.7756 46.9323L94.8421 30.7458C89.9911 27.2808 84.6451 25.5483 78.8041 25.5483C73.5571 25.5483 68.8051 26.7363 64.5481 29.1123C60.2911 31.4883 56.9251 34.7553 54.4501 38.9133C52.0741 43.0713 50.8861 47.7243 50.8861 52.8723V84.7998C50.8861 85.9878 51.3811 86.9283 52.3711 87.6213L63.5086 94.1553V52.7238ZM138.947 123.707C143.897 121.529 147.807 118.163 150.678 113.609C153.648 109.055 155.133 104.105 155.133 98.7588C155.133 93.9078 153.896 89.2548 151.421 84.7998C148.946 80.3448 145.728 76.9788 141.768 74.7018L113.999 58.6638C113.405 58.2678 112.86 58.1193 112.365 58.2183C111.87 58.2183 111.375 58.3668 110.88 58.6638L99.7426 64.9008L135.68 85.8393C136.769 86.4333 137.561 87.2253 138.056 88.2153C138.65 89.1063 138.947 90.1953 138.947 91.4823V123.707ZM109.098 48.2688C111.276 46.8828 113.454 46.8828 115.632 48.2688L143.847 64.7523C143.847 64.0593 143.847 63.1683 143.847 62.0793C143.847 57.3273 142.659 52.8228 140.283 48.5658C138.006 44.2098 134.69 40.7448 130.334 38.1708C126.077 35.5968 121.127 34.3098 115.484 34.3098C110.534 34.3098 106.227 35.3493 102.564 37.4283L74.6461 53.4663C73.6561 54.1593 73.1611 55.0998 73.1611 56.2878V69.0588L109.098 48.2688Z" />
|
||||
</vector>
|
||||
Loading…
Reference in New Issue
Block a user