feat: Codex OAuth 无头登录、调试日志面板、用量详情进度条与文档同步

- 实现 Codex OAuth PKCE 无头模式:生成授权链接、外部浏览器登录、粘贴回调 URL 交换 token

- 新增 DebugLog 内存 ring buffer 与设置页调试日志入口,Repository 关键路径埋点

- 修复 Codex refresh_token 刷新请求体为 form-urlencoded,RefreshResult 密封类保留错误详情

- ServiceDetailScreen 为 Codex 增加用量窗口进度条,Ollama 百分比精确到两位小数

- 同步 AGENTS.md、README.md
This commit is contained in:
WaterRain 2026-07-11 03:07:16 +00:00
parent fbae6701b8
commit 9d06644225
No known key found for this signature in database
17 changed files with 1479 additions and 53 deletions

View File

@ -23,7 +23,7 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
- ✅ 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 分钟自动刷新
- ✅ Codex / ChatGPT Plus — 支持 **OAuth PKCE 登录(无头模式)** 或粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新。OpenAI 采用 refresh_token 单次轮换机制,被外部工具使用后旧 token 立即失效,需重新 OAuth 登录或导入新 auth.json。
- ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532)
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
- ❌ OpenCode Zen / 小米 MiMo — 未实现
@ -37,6 +37,10 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
**调试与错误诊断**:
- ✅ `DebugLog` — 内存 ring buffer(200 条,线程安全),所有 Repository 关键路径(网络请求、Token 刷新、解析错误)均写入日志;设置页提供「调试日志」入口,无需连电脑即可在 APP 内查看 ERROR/WARN/INFO 三级日志。
- ✅ `RepositoryError.InvalidCredential` 支持自定义 `detail`,Codex `RefreshResult` 密封类明确区分刷新成功/失败原因,错误信息可透传到 Dashboard 卡片与调试日志。
**凭据回显红线**:
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。

View File

@ -50,6 +50,8 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
| 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 |
---
@ -57,7 +59,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key、Codex auth.json 或 Ollama Pro Cookie 才能拉取数据。
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Pro Cookie 才能拉取数据。
---

View File

@ -0,0 +1,51 @@
package com.rainy.token.data.debug
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedDeque
/**
* APP 内调试日志,内存 ring buffer(默认 200 条)。
* 各 Repository 在关键路径写入,用户可在设置页 → 调试日志 查看。
*/
object DebugLog {
enum class Level(val label: String) { INFO("INFO"), WARN("WARN"), ERROR("ERROR") }
data class Entry(
val timestamp: Long,
val tag: String,
val level: Level,
val message: String
) {
override fun toString(): String {
val fmt = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
return "${fmt.format(Calendar.getInstance().apply { timeInMillis = this@Entry.timestamp }.time)} ${level.label}/$tag: $message"
}
}
private const val MAX_SIZE = 200
private val deque = ConcurrentLinkedDeque<Entry>()
private val _entries = MutableStateFlow<List<Entry>>(emptyList())
val entries: StateFlow<List<Entry>> = _entries.asStateFlow()
fun log(tag: String, level: Level, message: String) {
val entry = Entry(System.currentTimeMillis(), tag, level, message)
deque.addFirst(entry)
while (deque.size > MAX_SIZE) deque.pollLast()
_entries.value = deque.toList()
}
fun i(tag: String, message: String) = log(tag, Level.INFO, message)
fun w(tag: String, message: String) = log(tag, Level.WARN, message)
fun e(tag: String, message: String) = log(tag, Level.ERROR, message)
fun clear() {
deque.clear()
_entries.value = emptyList()
}
}

View File

@ -0,0 +1,175 @@
package com.rainy.token.data.repository
import android.util.Base64
import com.rainy.token.data.debug.DebugLog
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.security.MessageDigest
import java.security.SecureRandom
/**
* Codex / ChatGPT Plus OAuth PKCE 辅助工具。
*
* 参考:7shi/codex-oauth 的 Python 实现,移植到 Kotlin/Android。
* 流程:
* 1. 生成 code_verifier(随机 96 字节 base64url)
* 2. 计算 code_challenge = SHA256(code_verifier) base64url 无填充
* 3. 构建 auth URL → 用户在 WebView 中登录
* 4. 拦截 localhost:1455/auth/callback?code=xxx&state=xxx
* 5. POST token endpoint 用 code + code_verifier 换 access/refresh token
* 6. 从 JWT (id_token / access_token) 提取 chatgpt_account_id
*/
object CodexOAuthHelper {
private const val TAG = "CodexOAuth"
const val AUTH_URL = "https://auth.openai.com/oauth/authorize"
const val TOKEN_URL = "https://auth.openai.com/oauth/token"
const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const val REDIRECT_URI = "http://localhost:1455/auth/callback"
const val SCOPE = "openid profile email offline_access"
const val CALLBACK_PREFIX = "http://localhost:1455/auth/callback"
private val json = Json { ignoreUnknownKeys = true }
/** PKCE 参数对 */
data class PkcePair(val codeVerifier: String, val codeChallenge: String)
/** 生成 PKCE code_verifier 和 code_challenge (S256) */
fun generatePkce(): PkcePair {
val randomBytes = ByteArray(96)
SecureRandom().nextBytes(randomBytes)
val codeVerifier = Base64.encodeToString(
randomBytes,
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray())
val codeChallenge = Base64.encodeToString(
digest,
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
return PkcePair(codeVerifier, codeChallenge)
}
/** 生成随机 state(CSRF 防护) */
fun generateState(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
/** 构建授权 URL */
fun buildAuthUrl(codeChallenge: String, state: String): String {
val params = mapOf(
"response_type" to "code",
"client_id" to CLIENT_ID,
"redirect_uri" to REDIRECT_URI,
"scope" to SCOPE,
"code_challenge" to codeChallenge,
"code_challenge_method" to "S256",
"state" to state,
"id_token_add_organizations" to "true",
"codex_cli_simplified_flow" to "true",
"originator" to "opencode"
)
// 手动拼 URL(不用 URLEncoder.encode,因为 OAuth 参数不需要编码特殊字符)
val query = params.entries.joinToString("&") { (k, v) ->
"$k=${java.net.URLEncoder.encode(v, "UTF-8")}"
}
return "$AUTH_URL?$query"
}
/** Token 交换响应 */
@Serializable
data class TokenResponse(
@kotlinx.serialization.SerialName("access_token") val accessToken: String,
@kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null,
@kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600,
@kotlinx.serialization.SerialName("id_token") val idToken: String? = null,
@kotlinx.serialization.SerialName("token_type") val tokenType: String? = null
)
/** 用 authorization code 换 token */
fun exchangeCode(
okHttpClient: OkHttpClient,
code: String,
codeVerifier: String
): TokenResponse? {
val formBody = buildString {
append("grant_type=authorization_code")
append("&code=").append(java.net.URLEncoder.encode(code, "UTF-8"))
append("&redirect_uri=").append(java.net.URLEncoder.encode(REDIRECT_URI, "UTF-8"))
append("&client_id=").append(CLIENT_ID)
append("&code_verifier=").append(codeVerifier)
}.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val request = Request.Builder()
.url(TOKEN_URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.post(formBody)
.build()
return try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
val errorBody = resp.body?.string()
DebugLog.e(TAG, "token exchange failed: HTTP ${resp.code} | $errorBody")
return@use null
}
val body = resp.body?.string() ?: return@use null
json.decodeFromString(TokenResponse.serializer(), body)
}
} catch (e: Exception) {
DebugLog.e(TAG, "token exchange exception: ${e::class.simpleName}: ${e.message}")
null
}
}
/**
* 从 JWT 中提取 chatgpt_account_id(不验证签名,只读 payload)。
* 3 级回退:
* 1. payload.chatgpt_account_id
* 2. payload["https://api.openai.com/auth"].chatgpt_account_id
* 3. payload.organizations[0].id
*/
fun extractAccountId(idToken: String?, accessToken: String?): String? {
for (token in listOfNotNull(idToken, accessToken)) {
val payload = decodeJWTPayload(token) ?: continue
// 1. top-level
payload["chatgpt_account_id"]?.jsonPrimitive?.content?.let { return it }
// 2. nested namespace
payload["https://api.openai.com/auth"]?.jsonObject
?.get("chatgpt_account_id")?.jsonPrimitive?.content?.let { return it }
// 3. organizations[0].id
(payload["organizations"] as? kotlinx.serialization.json.JsonArray)
?.firstOrNull()
?.let { it as? JsonObject }
?.get("id")?.jsonPrimitive?.content?.let { return it }
}
return null
}
/** 解码 JWT payload(第二段),不验证签名 */
private fun decodeJWTPayload(jwt: String): JsonObject? {
return try {
val parts = jwt.split(".")
if (parts.size < 2) return null
val payloadB64 = parts[1]
// base64url decode,补 padding
val decoded = Base64.decode(
payloadB64,
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
json.parseToJsonElement(String(decoded)).jsonObject
} catch (_: Exception) {
null
}
}
}

View File

@ -18,6 +18,8 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import android.util.Log
import com.rainy.token.data.debug.DebugLog
import java.io.IOException
import javax.inject.Singleton
@ -34,33 +36,51 @@ class CodexRepository(
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
private const val TAG = "Codex"
}
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.CODEX)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
if (credential !is Credential.CodexCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential())
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val effectiveCred = if (tokenNeedsRefresh(credential)) {
val refreshed = refreshToken(credential)
if (refreshed != null) { credentialRepository.save(refreshed); refreshed } else credential
DebugLog.i(TAG, "access_token 即将过期,尝试刷新(expiresAt=${credential.expiresAt})")
when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
DebugLog.i(TAG, "token 刷新成功,新 expiresAt=${r.cred.expiresAt}")
credentialRepository.save(r.cred); r.cred
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "token 主动刷新失败: ${r.reason}")
credential
}
}
} else credential
val usageResult = try {
fetchJson(WHAM_USAGE, effectiveCred.accessToken)
} catch (e: IOException) {
DebugLog.e(TAG, "网络异常: ${e.message}")
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)
DebugLog.w(TAG, "401 收到,尝试用 refresh_token 二次刷新")
when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
DebugLog.i(TAG, "二次刷新成功")
credentialRepository.save(r.cred)
try { fetchJson(WHAM_USAGE, r.cred.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)) }
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "二次刷新也失败: ${r.reason}")
return@withContext Result.failure(RepositoryError.InvalidCredential(r.reason))
}
}
} else return@withContext Result.failure(e)
} catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) }
@ -89,20 +109,63 @@ class CodexRepository(
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"))
private sealed class RefreshResult {
data class Success(val cred: Credential.CodexCredential) : RefreshResult()
data class Failure(val reason: String) : RefreshResult()
}
private fun refreshToken(cred: Credential.CodexCredential): RefreshResult {
// OpenAI auth endpoint 要求 form-urlencoded,不能用 JSON(否则返回 401)
val formBody = "grant_type=refresh_token&refresh_token=${cred.refreshToken}&client_id=$CLIENT_ID"
.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val request = Request.Builder().url(OAUTH_TOKEN_URL)
.header("Content-Type", "application/json")
.post(bodyStr.toRequestBody("application/json".toMediaType())).build()
.header("Content-Type", "application/x-www-form-urlencoded")
.post(formBody).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())
if (!resp.isSuccessful) {
val errorBody = resp.body?.string()
Log.w("CodexRepository", "token refresh failed: HTTP ${resp.code} ${resp.message} body=$errorBody")
DebugLog.e(TAG, "token refresh failed: HTTP ${resp.code} ${resp.message}" +
(errorBody?.take(200)?.let { " | $it" } ?: ""))
// 解析 OpenAI 错误响应,给出用户可读的提示
val reason = parseRefreshError(resp.code, errorBody)
return@use RefreshResult.Failure(reason)
}
val bodyStr = resp.body?.string() ?: return@use RefreshResult.Failure("响应体为空")
val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), bodyStr)
// OpenAI 轮换 refresh_token:响应中可能含新 refresh_token,也可能不含(不轮换时)
RefreshResult.Success(cred.copy(
accessToken = tr.accessToken,
refreshToken = tr.refreshToken ?: cred.refreshToken,
expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L,
lastVerifiedAt = System.currentTimeMillis()
))
}
} catch (e: Exception) { null }
} catch (e: Exception) {
Log.w("CodexRepository", "token refresh exception: ${e::class.simpleName}: ${e.message}")
DebugLog.e(TAG, "token refresh exception: ${e::class.simpleName}: ${e.message}")
RefreshResult.Failure("网络异常: ${e::class.simpleName}: ${e.message}")
}
}
/** 把 OpenAI token endpoint 的错误响应翻译成用户可读的中文提示 */
private fun parseRefreshError(httpCode: Int, errorBody: String?): String {
if (errorBody == null) return "HTTP $httpCode,无错误详情"
// 尝试提取 error.message 字段
val msg = try {
json.parseToJsonElement(errorBody).jsonObject["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content
} catch (_: Exception) { null }
return when {
msg != null && msg.contains("already been used") ->
"refresh_token 已被使用(被其他工具轮换),请重新导出 auth.json 并导入"
msg != null && msg.contains("sign in", ignoreCase = true) ->
"refresh_token 已失效,请重新登录获取新 auth.json"
httpCode == 401 && msg != null -> "认证失败: $msg"
httpCode == 401 -> "认证失败 (HTTP 401),refresh_token 可能已过期"
httpCode == 400 && msg != null -> "请求参数错误: $msg"
else -> "HTTP $httpCode: ${msg ?: errorBody.take(100)}"
}
}
private fun fetchJson(url: String, token: String): JsonObject {
@ -115,7 +178,11 @@ class CodexRepository(
.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)
if (!it.isSuccessful) {
Log.w("CodexRepository", "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code)
}
return json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")).jsonObject
}
}
@ -142,6 +209,5 @@ class CodexRepository(
private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; 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)
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)
}

View File

@ -99,7 +99,7 @@ class CommandCodeUsageRepository(
if (resp.code == 401 || resp.code == 403) {
val detail = if (body != null && body.length < 200) ":$body" else ""
return@withContext Result.failure(RepositoryError.InvalidCredential(
RuntimeException("HTTP ${resp.code}$detail")
"HTTP ${resp.code}$detail"
))
}
return@withContext Result.failure(RepositoryError.ServerError(resp.code))

View File

@ -76,7 +76,7 @@ class DeepSeekRepository @Inject constructor(
}
private fun mapHttpError(e: HttpException): RepositoryError = when (e.code()) {
401, 403 -> RepositoryError.InvalidCredential(e)
401, 403 -> RepositoryError.InvalidCredential(cause = e)
429 -> {
val retryAfter = e.response()?.headers()?.get("Retry-After")?.toLongOrNull()
RepositoryError.RateLimited(retryAfter)

View File

@ -5,8 +5,9 @@ package com.rainy.token.data.repository
*/
sealed class RepositoryError(message: String, cause: Throwable? = null) : Exception(message, cause) {
/** 凭据无效(如 401 Unauthorized) */
class InvalidCredential(cause: Throwable? = null) : RepositoryError("凭据无效", cause)
/** 凭据无效(如 401 Unauthorized),detail 为具体原因 */
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
/** 限流(429 Too Many Requests) */
class RateLimited(val retryAfterSeconds: Long? = null) :

View File

@ -35,6 +35,7 @@ import androidx.navigation.navArgument
import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.rememberWindowSizeClass
import com.rainy.token.ui.components.DebugLogScreen
import com.rainy.token.ui.components.TipsScreen
import com.rainy.token.ui.dashboard.DashboardScreen
import com.rainy.token.ui.dashboard.UsageChartViewModel
@ -48,6 +49,7 @@ import com.rainy.token.ui.settings.CredentialEditScreen
import com.rainy.token.ui.settings.SettingsScreen
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.webview.CodexOAuthScreen
import com.rainy.token.ui.webview.WebViewLoginScreen
/**
@ -63,6 +65,8 @@ object Routes {
const val DASHBOARD = "dashboard"
const val SETTINGS = "settings"
const val TIPS = "tips"
const val DEBUG_LOG = "debug_log"
const val CODEX_OAUTH = "codex_oauth"
const val CREDENTIAL_EDIT = "credential_edit/{type}"
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}"
@ -243,12 +247,22 @@ private fun CompactNavHost() {
SettingsScreen(
onBack = guardedPop,
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) },
onOpenTips = { navController.navigate(Routes.TIPS) }
onOpenTips = { navController.navigate(Routes.TIPS) },
onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) }
)
}
composable(Routes.TIPS) {
TipsScreen(onBack = guardedPop)
}
composable(Routes.DEBUG_LOG) {
DebugLogScreen(onBack = guardedPop)
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = guardedPop,
onSuccess = guardedPop
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(navArgument("type") { type = NavType.StringType })
@ -258,7 +272,8 @@ private fun CompactNavHost() {
service = type,
onBack = guardedPop,
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
onWebViewLoginSuccess = { }
onWebViewLoginSuccess = { },
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(
@ -422,12 +437,22 @@ private fun ExpandedDetailPane(
onEditCredential = { type ->
settingsNavController.navigate(Routes.credentialEdit(type))
},
onOpenTips = { settingsNavController.navigate("tips") }
onOpenTips = { settingsNavController.navigate("tips") },
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
)
}
composable("tips") {
TipsScreen(onBack = { settingsNavController.popBackStack() })
}
composable("debug_log") {
DebugLogScreen(onBack = { settingsNavController.popBackStack() })
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = { settingsNavController.popBackStack() },
onSuccess = { settingsNavController.popBackStack() }
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(navArgument("type") { type = NavType.StringType })
@ -439,7 +464,8 @@ private fun ExpandedDetailPane(
onStartWebViewLogin = { svc ->
settingsNavController.navigate(Routes.webviewLogin(svc))
},
onWebViewLoginSuccess = { }
onWebViewLoginSuccess = { },
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(

View File

@ -0,0 +1,150 @@
package com.rainy.token.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugLogScreen(onBack: () -> Unit) {
val entries by DebugLog.entries.collectAsStateWithLifecycle()
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
"调试日志",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = "返回",
tint = StrawberryPink
)
}
},
actions = {
TextButton(onClick = { DebugLog.clear() }) {
Text("清空", color = StrawberryPink)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { innerPadding ->
if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
"暂无日志记录\n刷新 Codex 用量后这里会出现调试信息",
style = MaterialTheme.typography.bodyMedium,
color = InkMuted,
modifier = Modifier.padding(32.dp)
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
val levelColor = when (entry.level) {
DebugLog.Level.INFO -> InkMuted
DebugLog.Level.WARN -> Color(0xFFFFA726)
DebugLog.Level.ERROR -> Color(0xFFE91E63)
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = entry.level.label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = levelColor
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = entry.tag,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = InkMuted
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = entry.toString().substringBefore(' '),
style = MaterialTheme.typography.labelSmall,
color = InkMuted.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = entry.message,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
}

View File

@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -137,8 +138,8 @@ fun ServiceDetailScreen(
item { CommandCodeGoUsageCard(uiState.state) }
}
ServiceType.CODEX -> {
// Codex 详情:暂无专用详情卡片,用通用余额展示即可
}
item { CodexUsageCard(uiState.state) }
}
ServiceType.OLLAMA -> {
item { OllamaUsageCard(uiState.state) }
}
@ -211,7 +212,7 @@ private fun CommandCodeGoUsageCard(state: State) {
val fiveHourUsed = extras["fiveHour.used"]?.toDoubleOrNull()
val fiveHourCap = extras["fiveHour.cap"]?.toDoubleOrNull()
if (fiveHourUsed != null && fiveHourCap != null && fiveHourCap > 0) {
val pct = ((fiveHourUsed / fiveHourCap) * 100).toInt().coerceIn(0, 100)
val pct = ((fiveHourUsed / fiveHourCap) * 100).toFloat().coerceIn(0f, 100f)
UsageWindowRow(
label = "5 小时滚动",
pct = pct,
@ -226,7 +227,7 @@ private fun CommandCodeGoUsageCard(state: State) {
val weeklyUsed = extras["weekly.used"]?.toDoubleOrNull()
val weeklyCap = extras["weekly.cap"]?.toDoubleOrNull()
if (weeklyUsed != null && weeklyCap != null && weeklyCap > 0) {
val pct = ((weeklyUsed / weeklyCap) * 100).toInt().coerceIn(0, 100)
val pct = ((weeklyUsed / weeklyCap) * 100).toFloat().coerceIn(0f, 100f)
UsageWindowRow(
label = "本周",
pct = pct,
@ -240,7 +241,7 @@ private fun CommandCodeGoUsageCard(state: State) {
// 3. 本月(最下面)
if (monthlyTotal != null && monthlyTotal > 0) {
val used = monthlyTotal - monthlyRemaining
val pct = ((used / monthlyTotal) * 100).toInt().coerceIn(0, 100)
val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f)
UsageWindowRow(
label = "本月",
pct = pct,
@ -314,7 +315,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
Spacer(modifier = Modifier.height(12.dp))
UsageWindowRow(
label = "5 小时滚动",
pct = extras["rolling.pct"]?.toIntOrNull(),
pct = extras["rolling.pct"]?.toFloatOrNull(),
resetInSec = extras["rolling.resetInSec"]?.toLongOrNull()
)
Spacer(modifier = Modifier.height(14.dp))
@ -322,7 +323,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
Spacer(modifier = Modifier.height(14.dp))
UsageWindowRow(
label = "本周",
pct = extras["weekly.pct"]?.toIntOrNull(),
pct = extras["weekly.pct"]?.toFloatOrNull(),
resetInSec = extras["weekly.resetInSec"]?.toLongOrNull()
)
Spacer(modifier = Modifier.height(14.dp))
@ -330,7 +331,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
Spacer(modifier = Modifier.height(14.dp))
UsageWindowRow(
label = "本月",
pct = extras["monthly.pct"]?.toIntOrNull(),
pct = extras["monthly.pct"]?.toFloatOrNull(),
resetInSec = extras["monthly.resetInSec"]?.toLongOrNull()
)
}
@ -338,8 +339,8 @@ private fun OpenCodeGoWindowsCard(state: State) {
}
@Composable
private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) {
val pctValue = (pct ?: 0).coerceIn(0, 100).toFloat()
private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) {
val pctValue = (pct ?: 0f).coerceIn(0f, 100f)
Column {
Row(
modifier = Modifier.fillMaxWidth(),
@ -353,7 +354,7 @@ private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) {
)
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = (pct ?: 0).toString(),
text = String.format(Locale.US, "%.${decimals}f", pctValue),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
@ -390,6 +391,96 @@ private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) {
}
}
/**
* Codex 专属:用量窗口进度卡。
*
* 数据从 balance.extras 中的 window_*.label / remainingPct / resetAt 解析,
* 展示每个窗口的已用百分比与重置倒计时。
*/
@Composable
private fun CodexUsageCard(state: State) {
val balance = when (state) {
is State.Fresh -> state.data
is State.Stale -> state.data
is State.Error -> state.cached
else -> null
}
val extras = balance?.extras ?: return
val plan = extras["plan"].orEmpty()
val windows = remember(extras) { extractCodexWindows(extras) }
if (windows.isEmpty()) return
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "用量窗口",
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
Spacer(modifier = Modifier.weight(1f))
if (plan.isNotBlank()) {
Text(
text = plan,
style = MaterialTheme.typography.labelLarge,
color = StrawberryPink,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(12.dp))
windows.forEachIndexed { index, window ->
if (index > 0) {
Spacer(modifier = Modifier.height(14.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(14.dp))
}
val resetInSec = window.resetAt?.let {
(it - System.currentTimeMillis()) / 1000
}?.takeIf { it > 0 }
UsageWindowRow(
label = window.label,
pct = window.usedPct,
resetInSec = resetInSec,
decimals = 2
)
}
}
}
}
private data class CodexWindow(
val label: String,
val usedPct: Float,
val resetAt: Long?
)
private fun extractCodexWindows(extras: Map<String, String>): List<CodexWindow> {
val result = mutableListOf<CodexWindow>()
var i = 0
while (true) {
val rawLabel = extras["window_${i}.label"] ?: break
val remaining = extras["window_${i}.remainingPct"]?.toFloatOrNull() ?: 0f
val resetAt = extras["window_${i}.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
val usedPct = (100f - remaining).coerceIn(0f, 100f)
result.add(CodexWindow(formatCodexWindowLabel(rawLabel), usedPct, resetAt))
i++
}
return result
}
private fun formatCodexWindowLabel(raw: String): String = when (raw.lowercase()) {
"5h" -> "5 小时"
"7d", "每周" -> "本周"
"30d", "每月" -> "本月"
else -> raw
}
/**
* Ollama Pro 专属:5h + 每周用量窗口卡 + 模型级调用次数。
*/
@ -431,8 +522,9 @@ private fun OllamaUsageCard(state: State) {
Spacer(modifier = Modifier.height(12.dp))
UsageWindowRow(
label = "5 小时",
pct = extras["session.pct"]?.toFloatOrNull()?.toInt(),
resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
pct = extras["session.pct"]?.toFloatOrNull(),
resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 },
decimals = 2
)
if (sessionModels.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp))
@ -443,8 +535,9 @@ private fun OllamaUsageCard(state: State) {
Spacer(modifier = Modifier.height(14.dp))
UsageWindowRow(
label = "每周",
pct = extras["weekly.pct"]?.toFloatOrNull()?.toInt(),
resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
pct = extras["weekly.pct"]?.toFloatOrNull(),
resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 },
decimals = 2
)
if (weeklyModels.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp))

View File

@ -63,6 +63,7 @@ fun CredentialEditScreen(
onBack: () -> Unit,
onStartWebViewLogin: (ServiceType) -> Unit,
onWebViewLoginSuccess: (ServiceType) -> Unit,
onStartCodexOAuth: () -> Unit = {},
viewModel: CredentialEditViewModel = hiltViewModel()
) {
LaunchedEffect(service) { viewModel.bind(service) }
@ -129,7 +130,8 @@ fun CredentialEditScreen(
hasExisting = uiState.hasExisting,
onAuthJsonChange = viewModel::updateCodexAuthJson,
onSave = viewModel::saveCodexAuthJson,
onShowHelp = { showCodexHelp = true }
onShowHelp = { showCodexHelp = true },
onStartOAuth = onStartCodexOAuth
)
} else {
ApiKeyForm(
@ -529,14 +531,25 @@ private fun CodexAuthJsonForm(
hasExisting: Boolean,
onAuthJsonChange: (String) -> Unit,
onSave: () -> Unit,
onShowHelp: () -> Unit
onShowHelp: () -> Unit,
onStartOAuth: () -> Unit = {}
) {
Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium)
Text(
text = "从 codex-oauth-proxy 的 auth.json 复制完整内容粘贴到下方。APP 会自动解析并支持自动刷新。",
text = "推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Button(onClick = onStartOAuth, modifier = Modifier.fillMaxWidth()) {
Text("🔐 OAuth 登录(推荐)")
}
Text(
text = "── 或手动导入 ──",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
OutlinedTextField(
value = authJson,
onValueChange = onAuthJsonChange,

View File

@ -327,8 +327,13 @@ class CredentialEditViewModel @Inject constructor(
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content
val accountId = tokens["account_id"]?.jsonPrimitive?.content ?: ""
// 支持三种过期时间格式:
// expiresAt / expires_at → epoch 毫秒(绝对时间)
// expires_in → 相对秒数(token 有效期),转为 now + seconds*1000
// 无该字段 → 默认 10 天后过期(假定 token 尚未到期)
val expiresAt = tokens["expiresAt"]?.jsonPrimitive?.content?.toLongOrNull()
?: tokens["expires_at"]?.jsonPrimitive?.content?.toLongOrNull()
?: tokens["expires_in"]?.jsonPrimitive?.content?.toLongOrNull()?.let { System.currentTimeMillis() + it * 1000L }
?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {

View File

@ -61,6 +61,7 @@ fun SettingsScreen(
onBack: () -> Unit,
onEditCredential: (ServiceType) -> Unit,
onOpenTips: () -> Unit = {},
onOpenDebugLog: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -126,6 +127,9 @@ fun SettingsScreen(
item {
TipsCard(onClick = { onOpenTips() })
}
item {
DebugLogCard(onClick = { onOpenDebugLog() })
}
item {
Spacer(modifier = Modifier.padding(top = 8.dp))
AboutCard()
@ -243,3 +247,40 @@ private fun TipsCard(onClick: () -> Unit) {
}
}
}
@Composable
private fun DebugLogCard(onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔍",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "调试日志",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = InkMuted
)
Text(
text = "查看 token 刷新、网络请求等调试记录",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}

View File

@ -0,0 +1,308 @@
package com.rainy.token.ui.webview
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.ui.theme.StrawberryPink
/**
* Codex OAuth PKCE 登录页面。
*
* 两种模式:
* - WEBVIEW:APP 内 WebView 打开 OpenAI 授权页,拦截 localhost:1455 回调
* - HEADLESS:显示授权 URL + 复制按钮 + 在浏览器打开 + 粘贴回调 URL 输入框
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CodexOAuthScreen(
onBack: () -> Unit,
onSuccess: () -> Unit,
viewModel: CodexOAuthViewModel = hiltViewModel()
) {
LaunchedEffect(Unit) { viewModel.start(CodexOAuthMode.HEADLESS) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(uiState.loginSucceeded) {
if (uiState.loginSucceeded) onSuccess()
}
BackHandler(enabled = !uiState.loginSucceeded) { onBack() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Codex OAuth 登录") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "返回")
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when {
uiState.exchanging -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(color = StrawberryPink)
Text(
"正在交换 Token...",
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
uiState.error != null && uiState.mode == CodexOAuthMode.WEBVIEW -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
uiState.error!!,
style = MaterialTheme.typography.bodyLarge,
color = androidx.compose.ui.graphics.Color(0xFFE91E63)
)
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
Text("重试", color = StrawberryPink)
}
}
}
// 无头模式:显示授权 URL + 粘贴回调
uiState.mode == CodexOAuthMode.HEADLESS && uiState.authUrl.isNotEmpty() -> {
HeadlessOAuthContent(
authUrl = uiState.authUrl,
error = uiState.error,
onCopyUrl = { copyToClipboard(context, uiState.authUrl) },
onOpenInBrowser = { openInBrowser(context, uiState.authUrl) },
onSubmit = { url -> viewModel.submitCallbackUrl(url) },
onRetry = { viewModel.start(CodexOAuthMode.HEADLESS) }
)
}
// WebView 模式
uiState.authUrl.isNotEmpty() -> {
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url?.toString()
if (url != null && viewModel.onUrlChanged(url)) {
return true
}
return false
}
}
webChromeClient = WebChromeClient()
loadUrl(uiState.authUrl)
}
},
modifier = Modifier.fillMaxSize()
)
}
else -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(color = StrawberryPink)
Text(
"正在准备授权...",
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
}
/**
* 无头模式 UI:授权 URL + 操作按钮 + 回调 URL 粘贴输入框
*/
@Composable
private fun HeadlessOAuthContent(
authUrl: String,
error: String?,
onCopyUrl: () -> Unit,
onOpenInBrowser: () -> Unit,
onSubmit: (String) -> Unit,
onRetry: () -> Unit
) {
var callbackUrl by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "无头模式登录",
style = MaterialTheme.typography.titleMedium,
color = StrawberryPink
)
Text(
text = "1. 点击「在浏览器中打开」或复制下方链接到浏览器\n" +
"2. 在 OpenAI 页面完成登录和授权\n" +
"3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n" +
"4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n" +
"5. 点击「提交回调 URL」",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
// 授权 URL 预览
OutlinedTextField(
value = authUrl,
onValueChange = {},
label = { Text("授权链接") },
readOnly = true,
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onOpenInBrowser,
modifier = Modifier.weight(1f)
) {
Text("在浏览器中打开")
}
OutlinedButton(
onClick = onCopyUrl,
modifier = Modifier.weight(1f)
) {
Text("复制链接")
}
}
Text(
text = "── 登录后粘贴回调 URL ──",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
textAlign = TextAlign.Center
)
OutlinedTextField(
value = callbackUrl,
onValueChange = { callbackUrl = it },
label = { Text("回调 URL") },
placeholder = { Text("http://localhost:1455/auth/callback?code=...") },
minLines = 2,
maxLines = 6,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { onSubmit(callbackUrl) },
modifier = Modifier.fillMaxWidth(),
enabled = callbackUrl.isNotBlank()
) {
Text("提交回调 URL")
}
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color(0xFFE91E63),
modifier = Modifier.padding(top = 4.dp)
)
TextButton(onClick = onRetry) {
Text("重新生成授权链接", color = StrawberryPink)
}
}
}
}
private fun copyToClipboard(context: Context, text: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("auth_url", text))
}
private fun openInBrowser(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}

View File

@ -0,0 +1,153 @@
package com.rainy.token.ui.webview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CodexOAuthHelper
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import javax.inject.Inject
enum class CodexOAuthMode { WEBVIEW, HEADLESS }
/**
* Codex OAuth PKCE 登录 ViewModel。
*
* 两种模式:
* - WEBVIEW:APP 内 WebView 打开授权页,拦截 localhost:1455 回调
* - HEADLESS:生成授权 URL 让用户复制到外部浏览器,登录后粘贴回调 URL 回来
*/
@HiltViewModel
class CodexOAuthViewModel @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(CodexOAuthUiState())
val uiState: StateFlow<CodexOAuthUiState> = _uiState.asStateFlow()
private val TAG = "CodexOAuth"
fun start(mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW) {
val pkce = CodexOAuthHelper.generatePkce()
val state = CodexOAuthHelper.generateState()
val authUrl = CodexOAuthHelper.buildAuthUrl(pkce.codeChallenge, state)
DebugLog.i(TAG, "OAuth 流程启动 (${mode.name}),构建授权 URL")
_uiState.update {
CodexOAuthUiState(
mode = mode,
authUrl = authUrl,
codeVerifier = pkce.codeVerifier,
expectedState = state
)
}
}
/**
* WebView 拦截到 URL 变化时调用。
* 如果 URL 匹配 callback,返回 true 表示已处理。
*/
fun onUrlChanged(url: String?): Boolean {
if (url == null) return false
if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) return false
handleCallbackUrl(url)
return true
}
/**
* 无头模式:用户粘贴回调 URL 后调用。
*/
fun submitCallbackUrl(url: String) {
handleCallbackUrl(url.trim())
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
/**
* 处理回调 URL:解析 code/state,交换 token,保存凭据。
*/
private fun handleCallbackUrl(url: String) {
if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) {
_uiState.update { it.copy(error = "URL 应以 ${CodexOAuthHelper.CALLBACK_PREFIX} 开头") }
return
}
val uri = android.net.Uri.parse(url)
val code = uri.getQueryParameter("code")
val receivedState = uri.getQueryParameter("state")
val error = uri.getQueryParameter("error")
if (error != null) {
val errorDesc = uri.getQueryParameter("error_description") ?: error
DebugLog.e(TAG, "OAuth 授权失败: $errorDesc")
_uiState.update { it.copy(error = "授权失败: $errorDesc") }
return
}
if (receivedState != _uiState.value.expectedState) {
DebugLog.e(TAG, "OAuth state 不匹配(CSRF 防护)")
_uiState.update { it.copy(error = "State 不匹配,请重试") }
return
}
if (code.isNullOrBlank()) {
DebugLog.e(TAG, "OAuth 回调缺少 code 参数")
_uiState.update { it.copy(error = "回调缺少授权码") }
return
}
_uiState.update { it.copy(exchanging = true, error = null) }
val verifier = _uiState.value.codeVerifier ?: return
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
CodexOAuthHelper.exchangeCode(okHttpClient, code, verifier)
}
if (result == null) {
DebugLog.e(TAG, "token 交换失败")
_uiState.update { it.copy(exchanging = false, error = "Token 交换失败,请查看调试日志") }
return@launch
}
val accountId = CodexOAuthHelper.extractAccountId(result.idToken, result.accessToken)
val expiresAt = System.currentTimeMillis() + result.expiresIn * 1000L
DebugLog.i(TAG, "OAuth 登录成功,accountId=$accountId,expiresAt=$expiresAt")
val cred = Credential.CodexCredential(
service = ServiceType.CODEX,
accessToken = result.accessToken,
refreshToken = result.refreshToken ?: "",
accountId = accountId ?: "",
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(cred)
_uiState.update { it.copy(exchanging = false, loginSucceeded = true) }
}
}
}
data class CodexOAuthUiState(
val mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW,
val authUrl: String = "",
val codeVerifier: String? = null,
val expectedState: String? = null,
val exchanging: Boolean = false,
val loginSucceeded: Boolean = false,
val error: String? = null
)

338
tools/codex_oauth.py Normal file
View File

@ -0,0 +1,338 @@
import argparse
import base64
import json
import time
import httpx
from openai import OpenAI, AuthenticationError
AUTH_URL = "https://auth.openai.com/oauth/authorize"
TOKEN_URL = "https://auth.openai.com/oauth/token"
BASE_URL = "https://chatgpt.com/backend-api/wham"
# Originally the Codex CLI client ID; OpenCode uses the same value.
# No standard allocation mechanism exists for third-party apps.
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CLIENT_NAME = "sample-script"
CLIENT_VER = "0.0.1"
# seconds before expiry to trigger refresh
SAFETY_MARGIN = 30
__all__ = [
"AuthManager",
"extract_account_id",
"cmd_login",
]
def extract_account_id(id_token: str | None, access_token: str | None) -> str | None:
"""Extract account_id from JWT with 3-level fallback.
No signature verification needed — we only read the payload for account_id.
The '-len % 4' padding trick avoids adding '=' when length is already a multiple of 4.
Tries id_token first, then access_token, because the claim location varies by token.
"""
for token in [id_token, access_token]:
if not token:
continue
try:
payload_b64 = token.split(".")[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
except Exception:
continue
# 1. top-level chatgpt_account_id
if aid := payload.get("chatgpt_account_id"):
return aid
# 2. inside https://api.openai.com/auth namespace
if aid := payload.get("https://api.openai.com/auth", {}).get("chatgpt_account_id"):
return aid
# 3. organizations[0].id
orgs = payload.get("organizations", [])
if orgs and (aid := orgs[0].get("id")):
return aid
return None
class AuthManager:
def __init__(self, path: str = "auth.json", tokens: dict = None):
self.path = path
if tokens is not None:
self._set_data(tokens)
else:
self.data = self._load()
def _load(self) -> dict:
"""Returns {} on first run (no auth.json yet); FileNotFoundError is expected."""
try:
with open(self.path) as f:
return json.load(f)
except FileNotFoundError:
return {}
def _save(self):
with open(self.path, "w") as f:
json.dump(self.data, f, indent=2, ensure_ascii=False)
def _set_data(self, tokens: dict, fallback_account_id=None):
expires_in = tokens.get("expires_in") or 3600
expires = int(time.time() * 1000) + expires_in * 1000
id_token = tokens.get("id_token")
access_token = tokens.get("access_token")
account_id = extract_account_id(id_token, access_token) or fallback_account_id
data = {
"type": "oauth",
"access": access_token,
"refresh": tokens.get("refresh_token"),
"expires": expires,
}
if account_id:
data["accountId"] = account_id
self.data = data
def refresh(self):
"""POST grant_type=refresh_token; _set_data recalculates expiry and updates account_id."""
resp = httpx.post(
TOKEN_URL,
data={
"grant_type": "refresh_token",
"refresh_token": self.data["refresh"],
"client_id": CLIENT_ID,
},
)
if not (200 <= resp.status_code < 300):
raise RuntimeError(f"Token refresh failed: {resp.status_code}")
self._set_data(resp.json(), self.data.get("accountId"))
self._save()
def ensure_valid(self):
now_ms = int(time.time() * 1000)
if not self.data.get("access") or self.data.get("expires", 0) < now_ms + SAFETY_MARGIN * 1000:
self.refresh()
def make_client(self) -> OpenAI:
"""api_key accepts the OAuth access token directly;
the library formats it as "Authorization: Bearer <token>".
"""
headers = {"User-Agent": f"{CLIENT_NAME}/{CLIENT_VER}"}
if account_id := self.data.get("accountId"):
headers["ChatGPT-Account-Id"] = account_id
return OpenAI(
api_key=self.data["access"],
base_url=BASE_URL,
default_headers=headers,
)
def auto_refresh(self, f, *args, **kwargs):
"""Checks token validity before calling f, and retries once on HTTP 401.
f receives self as its first argument so it can call make_client()
after a refresh and get a client with the updated token.
"""
self.ensure_valid()
try:
f(self, *args, **kwargs)
except AuthenticationError:
self.refresh()
f(self, *args, **kwargs)
def cmd_login():
"""Run the Authorization Code + PKCE flow to obtain OAuth tokens.
Opens a browser for the user to log in, receives the auth code via a local
HTTP callback server, exchanges it for tokens, and saves them to auth.json.
"""
import datetime
import hashlib
import secrets
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
from authlib.integrations.httpx_client import OAuth2Client
# SCOPE and REDIRECT_URI are used only inside cmd_login.
SCOPE = "openid profile email offline_access"
REDIRECT_URI = "http://localhost:1455/auth/callback"
# authlib's OAuth2Client does not auto-add code_challenge even when
# code_challenge_method="S256" is passed to the constructor, so generate manually.
code_verifier = secrets.token_urlsafe(96)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
# authlib manages state and builds the authorization URL.
# code_challenge and code_challenge_method must be passed explicitly here.
# OpenAI-specific params are appended to the URL as extra query parameters.
oauth = OAuth2Client(client_id=CLIENT_ID, redirect_uri=REDIRECT_URI, scope=SCOPE)
auth_url, state = oauth.create_authorization_url(
AUTH_URL,
code_challenge=code_challenge,
code_challenge_method="S256",
id_token_add_organizations="true", # OpenAI-specific
codex_cli_simplified_flow="true", # OpenAI-specific
originator="opencode", # OpenAI-specific
)
# Verify state on callback to prevent CSRF (attacker-supplied auth codes).
class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/auth/callback":
params = parse_qs(parsed.query)
received_state = params.get("state", [None])[0]
error = None
code = None
if received_state != self.server.expected_state:
error = "CSRF error: state mismatch"
elif "error" in params:
error = params.get("error_description", ["Unknown error"])[0]
else:
code = params.get("code", [None])[0]
self.server.auth_code = code
self.server.error = error
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
if self.server.auth_code:
html = "<html><body><h1>Authentication successful</h1><p>You can close this tab.</p></body></html>"
else:
html = f"<html><body><h1>Authentication failed</h1><p>{self.server.error}</p></body></html>"
self.wfile.write(html.encode())
threading.Thread(target=self.server.shutdown).start()
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # suppress server logs
server = HTTPServer(("localhost", 1455), CallbackHandler)
server.auth_code = None
server.error = None
server.expected_state = state # state returned by create_authorization_url()
print("Opening browser for authentication...")
print(f"If the browser does not open, visit the following URL:\n\n{auth_url}\n")
webbrowser.open(auth_url)
server.serve_forever() # blocks until shutdown() is called from CallbackHandler
if server.error:
raise RuntimeError(f"Authentication error: {server.error}")
print("Exchanging tokens...")
# grant_type, client_id, and redirect_uri are added automatically by authlib;
# code_verifier must be passed explicitly for PKCE verification.
token = oauth.fetch_token(TOKEN_URL, code=server.auth_code, code_verifier=code_verifier)
# AuthManager computes expiry, extracts account_id, and builds self.data internally.
am = AuthManager(tokens=token)
am._save()
expires_dt = datetime.datetime.fromtimestamp(am.data["expires"] / 1000).strftime("%Y-%m-%d %H:%M:%S")
print("\n=== Authentication Info ===")
print(f"CODEX_ACCESS_TOKEN : {(am.data['access'] or '')[:40]}...")
print(f"CODEX_ACCOUNT_ID : {am.data.get('accountId')}")
print(f"Expiry : {expires_dt}")
print("\nSaved to auth.json")
def cmd_test(auth, model):
"""WHAM Responses API requirements (differ from Chat Completions API):
- content type must be "input_text" (not "text")
- store=False is required (omitting it causes an error)
- instructions (system prompt) is required
WHAM is stateless: full conversation history must be included in the input array.
For multi-turn sessions, pass prompt_cache_key=<uuid7> and
extra_headers={"session_id": <uuid7>} to enable server-side caching and reduce TTFT.
Reasoning summary (CoT) requires both effort and summary in reasoning={}.
Streaming events for reasoning summary:
- response.reasoning_summary_text.delta: incremental reasoning summary
- response.reasoning_summary_text.done: reasoning summary complete
Note: reasoning.encrypted_content in include is for multi-turn context
continuity, not for CoT display.
"""
client = auth.make_client()
in_reasoning = False
with client.responses.create(
model=model,
instructions="You are a helpful coding assistant.",
input=[{
"role": "user",
"content": [{"type": "input_text", "text": "What is your name?"}],
}],
store=False,
reasoning={"effort": "medium", "summary": "auto"},
include=[],
tools=[],
tool_choice="auto",
parallel_tool_calls=True,
stream=True,
) as stream:
in_answer = False
for event in stream:
if event.type == "response.reasoning_summary_text.delta":
if not in_reasoning:
print("[Thinking]", flush=True)
in_reasoning = True
print(event.delta, end="", flush=True)
elif event.type == "response.reasoning_summary_text.done":
print()
in_reasoning = False
elif event.type == "response.output_text.delta":
if not in_answer:
print("[Answer]", flush=True)
in_answer = True
print(event.delta, end="", flush=True)
print()
def cmd_list(auth):
"""WHAM /models differs from the standard API:
response key is "models" (not "data"), model identifier is "slug" (not "id"),
and client_version query parameter is required.
Use with_raw_response because the non-standard schema cannot be parsed by the
standard response object.
"""
client = auth.make_client()
response = client.models.with_raw_response.list(extra_query={"client_version": CLIENT_VER})
data = json.loads(response.text)
with open("models.json", "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print("Saved to models.json")
for model in data["models"]:
print(model["slug"])
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("login")
test_parser = subparsers.add_parser("test")
test_parser.add_argument("-m", "--model", default="gpt-5.1-codex-mini")
subparsers.add_parser("list")
args = parser.parse_args(argv)
if args.command == "login":
cmd_login()
else:
auth = AuthManager()
if args.command == "test":
auth.auto_refresh(cmd_test, args.model)
elif args.command == "list":
auth.auto_refresh(cmd_list)
return 0
if __name__ == "__main__":
raise SystemExit(main())