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:
parent
fbae6701b8
commit
9d06644225
@ -23,7 +23,7 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
|
|||||||
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
||||||
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
||||||
- ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`)
|
- ✅ 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)
|
- ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532)
|
||||||
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
|
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
|
||||||
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
||||||
@ -37,6 +37,10 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
|
|||||||
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
||||||
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
|
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
|
||||||
|
|
||||||
|
**调试与错误诊断**:
|
||||||
|
- ✅ `DebugLog` — 内存 ring buffer(200 条,线程安全),所有 Repository 关键路径(网络请求、Token 刷新、解析错误)均写入日志;设置页提供「调试日志」入口,无需连电脑即可在 APP 内查看 ERROR/WARN/INFO 三级日志。
|
||||||
|
- ✅ `RepositoryError.InvalidCredential` 支持自定义 `detail`,Codex `RefreshResult` 密封类明确区分刷新成功/失败原因,错误信息可透传到 Dashboard 卡片与调试日志。
|
||||||
|
|
||||||
**凭据回显红线**:
|
**凭据回显红线**:
|
||||||
|
|
||||||
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。
|
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。
|
||||||
|
|||||||
@ -50,6 +50,8 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
|
|||||||
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
|
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
|
||||||
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
|
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
|
||||||
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
|
| 🎀 **雨晴粉主题** | 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。
|
前往 [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 才能拉取数据。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
51
app/src/main/java/com/rainy/token/data/debug/DebugLog.kt
Normal file
51
app/src/main/java/com/rainy/token/data/debug/DebugLog.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import android.util.Log
|
||||||
|
import com.rainy.token.data.debug.DebugLog
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Singleton
|
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 OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||||
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
||||||
|
private const val TAG = "Codex"
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.CODEX)
|
val credential = credentialRepository.get(ServiceType.CODEX)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
|
||||||
if (credential !is Credential.CodexCredential)
|
if (credential !is Credential.CodexCredential)
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
|
||||||
|
|
||||||
val effectiveCred = if (tokenNeedsRefresh(credential)) {
|
val effectiveCred = if (tokenNeedsRefresh(credential)) {
|
||||||
val refreshed = refreshToken(credential)
|
DebugLog.i(TAG, "access_token 即将过期,尝试刷新(expiresAt=${credential.expiresAt})")
|
||||||
if (refreshed != null) { credentialRepository.save(refreshed); refreshed } else credential
|
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
|
} else credential
|
||||||
|
|
||||||
val usageResult = try {
|
val usageResult = try {
|
||||||
fetchJson(WHAM_USAGE, effectiveCred.accessToken)
|
fetchJson(WHAM_USAGE, effectiveCred.accessToken)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
|
DebugLog.e(TAG, "网络异常: ${e.message}")
|
||||||
return@withContext Result.failure(RepositoryError.Network(e))
|
return@withContext Result.failure(RepositoryError.Network(e))
|
||||||
} catch (e: RepositoryError) {
|
} catch (e: RepositoryError) {
|
||||||
if (e is RepositoryError.InvalidCredential && effectiveCred == credential) {
|
if (e is RepositoryError.InvalidCredential && effectiveCred == credential) {
|
||||||
val retry = refreshToken(credential)
|
DebugLog.w(TAG, "401 收到,尝试用 refresh_token 二次刷新")
|
||||||
if (retry != null) {
|
when (val r = refreshToken(credential)) {
|
||||||
credentialRepository.save(retry)
|
is RefreshResult.Success -> {
|
||||||
try { fetchJson(WHAM_USAGE, retry.accessToken) }
|
DebugLog.i(TAG, "二次刷新成功")
|
||||||
|
credentialRepository.save(r.cred)
|
||||||
|
try { fetchJson(WHAM_USAGE, r.cred.accessToken) }
|
||||||
catch (e2: RepositoryError) { return@withContext Result.failure(e2) }
|
catch (e2: RepositoryError) { return@withContext Result.failure(e2) }
|
||||||
catch (e2: IOException) { return@withContext Result.failure(RepositoryError.Network(e2)) }
|
catch (e2: IOException) { return@withContext Result.failure(RepositoryError.Network(e2)) }
|
||||||
catch (e2: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e2)) }
|
catch (e2: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e2)) }
|
||||||
} else return@withContext Result.failure(e)
|
}
|
||||||
|
is RefreshResult.Failure -> {
|
||||||
|
DebugLog.e(TAG, "二次刷新也失败: ${r.reason}")
|
||||||
|
return@withContext Result.failure(RepositoryError.InvalidCredential(r.reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
} else return@withContext Result.failure(e)
|
} else return@withContext Result.failure(e)
|
||||||
} catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) }
|
} catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) }
|
||||||
|
|
||||||
@ -89,20 +109,63 @@ class CodexRepository(
|
|||||||
private fun tokenNeedsRefresh(cred: Credential.CodexCredential): Boolean =
|
private fun tokenNeedsRefresh(cred: Credential.CodexCredential): Boolean =
|
||||||
System.currentTimeMillis() >= cred.expiresAt - REFRESH_BUFFER_MS
|
System.currentTimeMillis() >= cred.expiresAt - REFRESH_BUFFER_MS
|
||||||
|
|
||||||
private fun refreshToken(cred: Credential.CodexCredential): Credential.CodexCredential? {
|
private sealed class RefreshResult {
|
||||||
val bodyStr = json.encodeToString(OAuthRefreshRequest.serializer(),
|
data class Success(val cred: Credential.CodexCredential) : RefreshResult()
|
||||||
OAuthRefreshRequest("refresh_token", cred.refreshToken, CLIENT_ID, "openid profile email"))
|
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)
|
val request = Request.Builder().url(OAUTH_TOKEN_URL)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.post(bodyStr.toRequestBody("application/json".toMediaType())).build()
|
.post(formBody).build()
|
||||||
return try {
|
return try {
|
||||||
okHttpClient.newCall(request).execute().use { resp ->
|
okHttpClient.newCall(request).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) return@use null
|
if (!resp.isSuccessful) {
|
||||||
val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), resp.body?.string() ?: return@use null)
|
val errorBody = resp.body?.string()
|
||||||
cred.copy(accessToken = tr.accessToken, refreshToken = tr.refreshToken,
|
Log.w("CodexRepository", "token refresh failed: HTTP ${resp.code} ${resp.message} body=$errorBody")
|
||||||
expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L, lastVerifiedAt = System.currentTimeMillis())
|
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) {
|
||||||
|
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)}"
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchJson(url: String, token: String): JsonObject {
|
private fun fetchJson(url: String, token: String): JsonObject {
|
||||||
@ -115,7 +178,11 @@ class CodexRepository(
|
|||||||
.header("Authorization", "Bearer $token").get().build()
|
.header("Authorization", "Bearer $token").get().build()
|
||||||
val resp = okHttpClient.newCall(request).execute()
|
val resp = okHttpClient.newCall(request).execute()
|
||||||
resp.use {
|
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
|
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" }
|
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? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @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, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)
|
|
||||||
}
|
}
|
||||||
@ -99,7 +99,7 @@ class CommandCodeUsageRepository(
|
|||||||
if (resp.code == 401 || resp.code == 403) {
|
if (resp.code == 401 || resp.code == 403) {
|
||||||
val detail = if (body != null && body.length < 200) ":$body" else ""
|
val detail = if (body != null && body.length < 200) ":$body" else ""
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential(
|
return@withContext Result.failure(RepositoryError.InvalidCredential(
|
||||||
RuntimeException("HTTP ${resp.code}$detail")
|
"HTTP ${resp.code}$detail"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
||||||
|
|||||||
@ -76,7 +76,7 @@ class DeepSeekRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mapHttpError(e: HttpException): RepositoryError = when (e.code()) {
|
private fun mapHttpError(e: HttpException): RepositoryError = when (e.code()) {
|
||||||
401, 403 -> RepositoryError.InvalidCredential(e)
|
401, 403 -> RepositoryError.InvalidCredential(cause = e)
|
||||||
429 -> {
|
429 -> {
|
||||||
val retryAfter = e.response()?.headers()?.get("Retry-After")?.toLongOrNull()
|
val retryAfter = e.response()?.headers()?.get("Retry-After")?.toLongOrNull()
|
||||||
RepositoryError.RateLimited(retryAfter)
|
RepositoryError.RateLimited(retryAfter)
|
||||||
|
|||||||
@ -5,8 +5,9 @@ package com.rainy.token.data.repository
|
|||||||
*/
|
*/
|
||||||
sealed class RepositoryError(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
sealed class RepositoryError(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||||
|
|
||||||
/** 凭据无效(如 401 Unauthorized) */
|
/** 凭据无效(如 401 Unauthorized),detail 为具体原因 */
|
||||||
class InvalidCredential(cause: Throwable? = null) : RepositoryError("凭据无效", cause)
|
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
|
||||||
|
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
|
||||||
|
|
||||||
/** 限流(429 Too Many Requests) */
|
/** 限流(429 Too Many Requests) */
|
||||||
class RateLimited(val retryAfterSeconds: Long? = null) :
|
class RateLimited(val retryAfterSeconds: Long? = null) :
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import androidx.navigation.navArgument
|
|||||||
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.components.rememberWindowSizeClass
|
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.components.TipsScreen
|
||||||
import com.rainy.token.ui.dashboard.DashboardScreen
|
import com.rainy.token.ui.dashboard.DashboardScreen
|
||||||
import com.rainy.token.ui.dashboard.UsageChartViewModel
|
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.settings.SettingsScreen
|
||||||
import com.rainy.token.ui.theme.inkMuted
|
import com.rainy.token.ui.theme.inkMuted
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
|
import com.rainy.token.ui.webview.CodexOAuthScreen
|
||||||
import com.rainy.token.ui.webview.WebViewLoginScreen
|
import com.rainy.token.ui.webview.WebViewLoginScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,6 +65,8 @@ object Routes {
|
|||||||
const val DASHBOARD = "dashboard"
|
const val DASHBOARD = "dashboard"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val TIPS = "tips"
|
const val TIPS = "tips"
|
||||||
|
const val DEBUG_LOG = "debug_log"
|
||||||
|
const val CODEX_OAUTH = "codex_oauth"
|
||||||
const val CREDENTIAL_EDIT = "credential_edit/{type}"
|
const val CREDENTIAL_EDIT = "credential_edit/{type}"
|
||||||
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
|
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
|
||||||
const val WEBVIEW_LOGIN = "webview_login/{type}"
|
const val WEBVIEW_LOGIN = "webview_login/{type}"
|
||||||
@ -243,12 +247,22 @@ private fun CompactNavHost() {
|
|||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) },
|
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) {
|
composable(Routes.TIPS) {
|
||||||
TipsScreen(onBack = guardedPop)
|
TipsScreen(onBack = guardedPop)
|
||||||
}
|
}
|
||||||
|
composable(Routes.DEBUG_LOG) {
|
||||||
|
DebugLogScreen(onBack = guardedPop)
|
||||||
|
}
|
||||||
|
composable(Routes.CODEX_OAUTH) {
|
||||||
|
CodexOAuthScreen(
|
||||||
|
onBack = guardedPop,
|
||||||
|
onSuccess = guardedPop
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.CREDENTIAL_EDIT,
|
route = Routes.CREDENTIAL_EDIT,
|
||||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
@ -258,7 +272,8 @@ private fun CompactNavHost() {
|
|||||||
service = type,
|
service = type,
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
||||||
onWebViewLoginSuccess = { }
|
onWebViewLoginSuccess = { },
|
||||||
|
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@ -422,12 +437,22 @@ private fun ExpandedDetailPane(
|
|||||||
onEditCredential = { type ->
|
onEditCredential = { type ->
|
||||||
settingsNavController.navigate(Routes.credentialEdit(type))
|
settingsNavController.navigate(Routes.credentialEdit(type))
|
||||||
},
|
},
|
||||||
onOpenTips = { settingsNavController.navigate("tips") }
|
onOpenTips = { settingsNavController.navigate("tips") },
|
||||||
|
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("tips") {
|
composable("tips") {
|
||||||
TipsScreen(onBack = { settingsNavController.popBackStack() })
|
TipsScreen(onBack = { settingsNavController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
composable("debug_log") {
|
||||||
|
DebugLogScreen(onBack = { settingsNavController.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(Routes.CODEX_OAUTH) {
|
||||||
|
CodexOAuthScreen(
|
||||||
|
onBack = { settingsNavController.popBackStack() },
|
||||||
|
onSuccess = { settingsNavController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.CREDENTIAL_EDIT,
|
route = Routes.CREDENTIAL_EDIT,
|
||||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
@ -439,7 +464,8 @@ private fun ExpandedDetailPane(
|
|||||||
onStartWebViewLogin = { svc ->
|
onStartWebViewLogin = { svc ->
|
||||||
settingsNavController.navigate(Routes.webviewLogin(svc))
|
settingsNavController.navigate(Routes.webviewLogin(svc))
|
||||||
},
|
},
|
||||||
onWebViewLoginSuccess = { }
|
onWebViewLoginSuccess = { },
|
||||||
|
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Refresh
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -137,7 +138,7 @@ fun ServiceDetailScreen(
|
|||||||
item { CommandCodeGoUsageCard(uiState.state) }
|
item { CommandCodeGoUsageCard(uiState.state) }
|
||||||
}
|
}
|
||||||
ServiceType.CODEX -> {
|
ServiceType.CODEX -> {
|
||||||
// Codex 详情:暂无专用详情卡片,用通用余额展示即可
|
item { CodexUsageCard(uiState.state) }
|
||||||
}
|
}
|
||||||
ServiceType.OLLAMA -> {
|
ServiceType.OLLAMA -> {
|
||||||
item { OllamaUsageCard(uiState.state) }
|
item { OllamaUsageCard(uiState.state) }
|
||||||
@ -211,7 +212,7 @@ private fun CommandCodeGoUsageCard(state: State) {
|
|||||||
val fiveHourUsed = extras["fiveHour.used"]?.toDoubleOrNull()
|
val fiveHourUsed = extras["fiveHour.used"]?.toDoubleOrNull()
|
||||||
val fiveHourCap = extras["fiveHour.cap"]?.toDoubleOrNull()
|
val fiveHourCap = extras["fiveHour.cap"]?.toDoubleOrNull()
|
||||||
if (fiveHourUsed != null && fiveHourCap != null && fiveHourCap > 0) {
|
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(
|
UsageWindowRow(
|
||||||
label = "5 小时滚动",
|
label = "5 小时滚动",
|
||||||
pct = pct,
|
pct = pct,
|
||||||
@ -226,7 +227,7 @@ private fun CommandCodeGoUsageCard(state: State) {
|
|||||||
val weeklyUsed = extras["weekly.used"]?.toDoubleOrNull()
|
val weeklyUsed = extras["weekly.used"]?.toDoubleOrNull()
|
||||||
val weeklyCap = extras["weekly.cap"]?.toDoubleOrNull()
|
val weeklyCap = extras["weekly.cap"]?.toDoubleOrNull()
|
||||||
if (weeklyUsed != null && weeklyCap != null && weeklyCap > 0) {
|
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(
|
UsageWindowRow(
|
||||||
label = "本周",
|
label = "本周",
|
||||||
pct = pct,
|
pct = pct,
|
||||||
@ -240,7 +241,7 @@ private fun CommandCodeGoUsageCard(state: State) {
|
|||||||
// 3. 本月(最下面)
|
// 3. 本月(最下面)
|
||||||
if (monthlyTotal != null && monthlyTotal > 0) {
|
if (monthlyTotal != null && monthlyTotal > 0) {
|
||||||
val used = monthlyTotal - monthlyRemaining
|
val used = monthlyTotal - monthlyRemaining
|
||||||
val pct = ((used / monthlyTotal) * 100).toInt().coerceIn(0, 100)
|
val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f)
|
||||||
UsageWindowRow(
|
UsageWindowRow(
|
||||||
label = "本月",
|
label = "本月",
|
||||||
pct = pct,
|
pct = pct,
|
||||||
@ -314,7 +315,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
|
|||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
UsageWindowRow(
|
UsageWindowRow(
|
||||||
label = "5 小时滚动",
|
label = "5 小时滚动",
|
||||||
pct = extras["rolling.pct"]?.toIntOrNull(),
|
pct = extras["rolling.pct"]?.toFloatOrNull(),
|
||||||
resetInSec = extras["rolling.resetInSec"]?.toLongOrNull()
|
resetInSec = extras["rolling.resetInSec"]?.toLongOrNull()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
@ -322,7 +323,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
|
|||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
UsageWindowRow(
|
UsageWindowRow(
|
||||||
label = "本周",
|
label = "本周",
|
||||||
pct = extras["weekly.pct"]?.toIntOrNull(),
|
pct = extras["weekly.pct"]?.toFloatOrNull(),
|
||||||
resetInSec = extras["weekly.resetInSec"]?.toLongOrNull()
|
resetInSec = extras["weekly.resetInSec"]?.toLongOrNull()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
@ -330,7 +331,7 @@ private fun OpenCodeGoWindowsCard(state: State) {
|
|||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
UsageWindowRow(
|
UsageWindowRow(
|
||||||
label = "本月",
|
label = "本月",
|
||||||
pct = extras["monthly.pct"]?.toIntOrNull(),
|
pct = extras["monthly.pct"]?.toFloatOrNull(),
|
||||||
resetInSec = extras["monthly.resetInSec"]?.toLongOrNull()
|
resetInSec = extras["monthly.resetInSec"]?.toLongOrNull()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -338,8 +339,8 @@ private fun OpenCodeGoWindowsCard(state: State) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) {
|
private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) {
|
||||||
val pctValue = (pct ?: 0).coerceIn(0, 100).toFloat()
|
val pctValue = (pct ?: 0f).coerceIn(0f, 100f)
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@ -353,7 +354,7 @@ private fun UsageWindowRow(label: String, pct: Int?, resetInSec: Long?) {
|
|||||||
)
|
)
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
Text(
|
Text(
|
||||||
text = (pct ?: 0).toString(),
|
text = String.format(Locale.US, "%.${decimals}f", pctValue),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
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 + 每周用量窗口卡 + 模型级调用次数。
|
* Ollama Pro 专属:5h + 每周用量窗口卡 + 模型级调用次数。
|
||||||
*/
|
*/
|
||||||
@ -431,8 +522,9 @@ private fun OllamaUsageCard(state: State) {
|
|||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
UsageWindowRow(
|
UsageWindowRow(
|
||||||
label = "5 小时",
|
label = "5 小时",
|
||||||
pct = extras["session.pct"]?.toFloatOrNull()?.toInt(),
|
pct = extras["session.pct"]?.toFloatOrNull(),
|
||||||
resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 },
|
||||||
|
decimals = 2
|
||||||
)
|
)
|
||||||
if (sessionModels.isNotEmpty()) {
|
if (sessionModels.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
@ -443,8 +535,9 @@ private fun OllamaUsageCard(state: State) {
|
|||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
UsageWindowRow(
|
UsageWindowRow(
|
||||||
label = "每周",
|
label = "每周",
|
||||||
pct = extras["weekly.pct"]?.toFloatOrNull()?.toInt(),
|
pct = extras["weekly.pct"]?.toFloatOrNull(),
|
||||||
resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 },
|
||||||
|
decimals = 2
|
||||||
)
|
)
|
||||||
if (weeklyModels.isNotEmpty()) {
|
if (weeklyModels.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|||||||
@ -63,6 +63,7 @@ fun CredentialEditScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onStartWebViewLogin: (ServiceType) -> Unit,
|
onStartWebViewLogin: (ServiceType) -> Unit,
|
||||||
onWebViewLoginSuccess: (ServiceType) -> Unit,
|
onWebViewLoginSuccess: (ServiceType) -> Unit,
|
||||||
|
onStartCodexOAuth: () -> Unit = {},
|
||||||
viewModel: CredentialEditViewModel = hiltViewModel()
|
viewModel: CredentialEditViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(service) { viewModel.bind(service) }
|
LaunchedEffect(service) { viewModel.bind(service) }
|
||||||
@ -129,7 +130,8 @@ fun CredentialEditScreen(
|
|||||||
hasExisting = uiState.hasExisting,
|
hasExisting = uiState.hasExisting,
|
||||||
onAuthJsonChange = viewModel::updateCodexAuthJson,
|
onAuthJsonChange = viewModel::updateCodexAuthJson,
|
||||||
onSave = viewModel::saveCodexAuthJson,
|
onSave = viewModel::saveCodexAuthJson,
|
||||||
onShowHelp = { showCodexHelp = true }
|
onShowHelp = { showCodexHelp = true },
|
||||||
|
onStartOAuth = onStartCodexOAuth
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ApiKeyForm(
|
ApiKeyForm(
|
||||||
@ -529,14 +531,25 @@ private fun CodexAuthJsonForm(
|
|||||||
hasExisting: Boolean,
|
hasExisting: Boolean,
|
||||||
onAuthJsonChange: (String) -> Unit,
|
onAuthJsonChange: (String) -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
onShowHelp: () -> Unit
|
onShowHelp: () -> Unit,
|
||||||
|
onStartOAuth: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium)
|
Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
text = "从 codex-oauth-proxy 的 auth.json 复制完整内容粘贴到下方。APP 会自动解析并支持自动刷新。",
|
text = "推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.outline
|
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(
|
OutlinedTextField(
|
||||||
value = authJson,
|
value = authJson,
|
||||||
onValueChange = onAuthJsonChange,
|
onValueChange = onAuthJsonChange,
|
||||||
|
|||||||
@ -327,8 +327,13 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
|
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
|
||||||
val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content
|
val refreshToken = tokens["refresh_token"]?.jsonPrimitive?.content
|
||||||
val accountId = tokens["account_id"]?.jsonPrimitive?.content ?: ""
|
val accountId = tokens["account_id"]?.jsonPrimitive?.content ?: ""
|
||||||
|
// 支持三种过期时间格式:
|
||||||
|
// expiresAt / expires_at → epoch 毫秒(绝对时间)
|
||||||
|
// expires_in → 相对秒数(token 有效期),转为 now + seconds*1000
|
||||||
|
// 无该字段 → 默认 10 天后过期(假定 token 尚未到期)
|
||||||
val expiresAt = tokens["expiresAt"]?.jsonPrimitive?.content?.toLongOrNull()
|
val expiresAt = tokens["expiresAt"]?.jsonPrimitive?.content?.toLongOrNull()
|
||||||
?: tokens["expires_at"]?.jsonPrimitive?.content?.toLongOrNull()
|
?: tokens["expires_at"]?.jsonPrimitive?.content?.toLongOrNull()
|
||||||
|
?: tokens["expires_in"]?.jsonPrimitive?.content?.toLongOrNull()?.let { System.currentTimeMillis() + it * 1000L }
|
||||||
?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000
|
?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000
|
||||||
|
|
||||||
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
|
if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
|
||||||
|
|||||||
@ -61,6 +61,7 @@ fun SettingsScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEditCredential: (ServiceType) -> Unit,
|
onEditCredential: (ServiceType) -> Unit,
|
||||||
onOpenTips: () -> Unit = {},
|
onOpenTips: () -> Unit = {},
|
||||||
|
onOpenDebugLog: () -> Unit = {},
|
||||||
viewModel: SettingsViewModel = hiltViewModel()
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@ -126,6 +127,9 @@ fun SettingsScreen(
|
|||||||
item {
|
item {
|
||||||
TipsCard(onClick = { onOpenTips() })
|
TipsCard(onClick = { onOpenTips() })
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
DebugLogCard(onClick = { onOpenDebugLog() })
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||||
AboutCard()
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
308
app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt
Normal file
308
app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
@ -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
338
tools/codex_oauth.py
Normal 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())
|
||||||
Loading…
Reference in New Issue
Block a user