diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1156d6f..3596af6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 33 - versionName = "1.7.13" + versionCode = 34 + versionName = "1.7.14" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt index c7d13bf..eb6ce4a 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt @@ -195,52 +195,76 @@ class WebViewLoginViewModel @Inject constructor( DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败") return } - // 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志) + // 只记录参数名(不含 token 值,避免把 token 明文落调试日志) DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}") - // Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串 + // Trae 回调可能走两套: + // 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程) + // 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT val userJwtRaw = parsed.queryParameter("userJwt") ?: parsed.queryParameter("user_jwt") - ?: run { - DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数") - return - } - val userJwt = try { - Json.parseToJsonElement(userJwtRaw).jsonObject - } catch (e: Exception) { - DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}") - return - } - val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull - if (token.isNullOrBlank()) { - DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})") - return - } - DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}") - // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。 - // 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有 - // 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。 - val deviceId = traeDeviceId() - // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。 - val userId = parsed.queryParameter("userInfo")?.let { raw -> - runCatching { - val u = Json.parseToJsonElement(raw).jsonObject - u["userId"]?.jsonPrimitive?.contentOrNull - ?: u["user_id"]?.jsonPrimitive?.contentOrNull - ?: u["id"]?.jsonPrimitive?.contentOrNull - ?: u["accountId"]?.jsonPrimitive?.contentOrNull - }.getOrNull() - } - DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}") - + ?: "" viewModelScope.launch { - // 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间, - // 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常 + val userJwt = runCatching { + Json.parseToJsonElement(userJwtRaw).jsonObject + }.getOrNull() ?: JsonObject(emptyMap()) + + var token = userJwt["Token"]?.jsonPrimitive?.contentOrNull + ?: userJwt["token"]?.jsonPrimitive?.contentOrNull + ?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull + ?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull + var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull + ?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull + ?: parsed.queryParameter("refreshToken") + ?: parsed.queryParameter("refresh_token") + ?: parsed.queryParameter("data") + val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull + ?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull + ?: parsed.queryParameter("clientId") + ?: parsed.queryParameter("client_id") + ?: "en1oxy7wnw8j9n" + + // 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT + if (token.isNullOrBlank() && !refresh.isNullOrBlank()) { + val exchangeHost = parsed.queryParameter("host")?.takeIf { it.startsWith("http") } + ?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn" + val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost) + if (exchanged != null) { + token = exchanged.token + refresh = exchanged.refreshToken + DebugLog.i("TraeLogin", "onTraeCallback: refreshToken 兑换成功(host=${exchangeHost})") + } else { + DebugLog.w("TraeLogin", "onTraeCallback: refreshToken 兑换失败(host=${exchangeHost})") + } + } + + if (token.isNullOrBlank()) { + DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})") + _uiState.update { + it.copy(error = UiText.Resource(R.string.error_login_token_missing)) + } + return@launch + } + + // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。 + val deviceId = traeDeviceId() + // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。 + val parsedUserId = parsed.queryParameter("userInfo")?.let { raw -> + runCatching { + val u = Json.parseToJsonElement(raw).jsonObject + u["userId"]?.jsonPrimitive?.contentOrNull + ?: u["user_id"]?.jsonPrimitive?.contentOrNull + ?: u["id"]?.jsonPrimitive?.contentOrNull + ?: u["accountId"]?.jsonPrimitive?.contentOrNull + }.getOrNull() + } + DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (parsedUserId.isNullOrBlank()) "null" else "ok"}") + val credential = Credential.TraeCredential( service = ServiceType.TRAE, jwt = token, region = if (region == "INTL") "INTL" else "CN", deviceId = deviceId, - userId = userId?.takeIf { it.isNotBlank() }, + userId = parsedUserId?.takeIf { it.isNotBlank() }, lastVerifiedAt = System.currentTimeMillis() ) val effectiveId = credentialRepository.save(credential, accountId) @@ -251,6 +275,49 @@ class WebViewLoginViewModel @Inject constructor( } } + /** 使用 refreshToken 向 Trae OAuth 兑换 Cloud-IDE-JWT(老流程回调没有 userJwt)。 */ + private data class TraeExchangeResult(val token: String, val refreshToken: String) + + private suspend fun exchangeTraeRefreshToken( + refreshToken: String, + clientId: String, + host: String + ): TraeExchangeResult? = withContext(Dispatchers.IO) { + try { + val payload = buildString { + append("{\"ClientID\":\"").append(clientId) + .append("\",\"RefreshToken\":\"").append(refreshToken) + .append("\",\"ClientSecret\":\"-\",\"UserID\":\"\"}") + } + val request = Request.Builder() + .url(host.trimEnd('/') + "/cloudide/api/v3/trae/oauth/ExchangeToken") + .addHeader("Content-Type", "application/json") + .post(payload.toRequestBody("application/json".toMediaTypeOrNull())) + .build() + okHttpClient.newCall(request).execute().use { resp -> + val body = resp.body?.string().orEmpty() + DebugLog.i("TraeLogin", "ExchangeToken: http=${resp.code} bodyLen=${body.length}") + val root = runCatching { Json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return@use null + val data = root["Result"]?.jsonObject ?: root["result"]?.jsonObject ?: root + val token = data["Token"]?.jsonPrimitive?.contentOrNull + ?: data["token"]?.jsonPrimitive?.contentOrNull + ?: data["AccessToken"]?.jsonPrimitive?.contentOrNull + ?: data["accessToken"]?.jsonPrimitive?.contentOrNull + if (token.isNullOrBlank()) { + DebugLog.w("TraeLogin", "ExchangeToken: 返回无 Token(keys=${data.keys})") + return@use null + } + val newRefresh = data["RefreshToken"]?.jsonPrimitive?.contentOrNull + ?: data["refreshToken"]?.jsonPrimitive?.contentOrNull + ?: refreshToken + TraeExchangeResult(token, newRefresh) + } + } catch (e: Exception) { + DebugLog.w("TraeLogin", "ExchangeToken: 异常=" + (e.message ?: "unknown")) + null + } + } + // ─── WorkBuddy ───────────────────────────────────────────────────────── private fun bindWorkBuddy() { diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index d2d2811..ee87539 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -423,6 +423,7 @@ 请先选择模型 未抓到 Cookie,请确认已登录 未配置登录 URL + 登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴 登录超时,请重试 URL 应以 %1$s 开头 授权失败: %1$s diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index cd8f83e..2c447a4 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -423,6 +423,7 @@ 請先選擇模型 未抓到 Cookie,請確認已登入 未配置登入 URL + 登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上 登入逾時,請重試 URL 應以 %1$s 開頭 授權失敗: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfed392..7b14865 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -426,6 +426,7 @@ Please select a model first No cookie captured, please make sure you\'re logged in No login URL configured + Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually. Login timed out, please retry URL should start with %1$s Authorization failed: %1$s