fix(trae): 回调兼容 localhost/fragment,pageStarted 兜底拦截

- 识别任意 127.0.0.1/localhost:18080/authorize 回调(http/https、query/fragment)
- shouldOverrideUrlLoading 未触发时在 onPageStarted 拦截并停止加载,避免卡 Authenticating
This commit is contained in:
Liuxinyu176 2026-10-09 18:01:06 +08:00
parent 101e2bc240
commit 68cdbcf0b0
3 changed files with 44 additions and 15 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token" applicationId = "com.rainy.token"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 34 versionCode = 35
versionName = "1.7.14" versionName = "1.7.15"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -190,8 +190,9 @@ fun WebViewLoginScreen(
} }
fun handleOverrideUrl(url: String): Boolean { fun handleOverrideUrl(url: String): Boolean {
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt // Trae OAuth 回调:拦截 127.0.0.1/localhost:18080/authorize,提取 userJwt
if (url.startsWith("http://127.0.0.1:18080/authorize")) { // (部分流程会从 https 跳到 http,或把参数放 fragment)
if (isTraeLoginCallback(url)) {
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}") DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
viewModel.onTraeCallback(url) viewModel.onTraeCallback(url)
return true return true
@ -199,10 +200,24 @@ fun WebViewLoginScreen(
return false return false
} }
private fun isTraeLoginCallback(url: String): Boolean {
val u = runCatching { android.net.Uri.parse(url) }.getOrNull()
return u?.host in setOf("127.0.0.1", "localhost") &&
u?.port == 18080 &&
u?.path == "/authorize"
}
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
pageLoading = true pageLoading = true
pageError = null pageError = null
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}") DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}")
// 保险:某些重定向不一定走 shouldOverrideUrlLoading,
// 在 pageStarted 也拦一次回调地址,避免页面停在 Authenticating。
if (url != null && isTraeLoginCallback(url)) {
viewModel.onTraeCallback(url)
view?.stopLoading()
return
}
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
} }

View File

@ -191,17 +191,31 @@ class WebViewLoginViewModel @Inject constructor(
if (service != ServiceType.TRAE) return if (service != ServiceType.TRAE) return
if (_uiState.value.loginSucceeded) return if (_uiState.value.loginSucceeded) return
val callbackUri = android.net.Uri.parse(url)
// 参数同时支持 query 和 fragment(部分 OAuth 回调用 # 而非 ?)
fun param(name: String): String? {
callbackUri.getQueryParameter(name)?.let { return it }
val frag = callbackUri.fragment
if (!frag.isNullOrBlank()) {
frag.split("&").forEach { pair ->
val kv = pair.split("=", limit = 2)
if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1])
if (kv.size == 1 && kv[0] == name) return ""
}
}
return null
}
val parsed = url.toHttpUrlOrNull() ?: run { val parsed = url.toHttpUrlOrNull() ?: run {
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败") DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
return return
} }
// 只记录参数名(不含 token 值,避免把 token 明文落调试日志) // 只记录参数名(不含 token 值,避免把 token 明文落调试日志);query+both
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}") DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,query=${callbackUri.query?.split("&")?.map { it.substringBefore("=") } ?: emptyList()} fragmentKeys=${callbackUri.fragment?.split("&")?.map { it.substringBefore("=") } ?: emptyList()}")
// Trae 回调可能走两套: // Trae 回调可能走两套:
// 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程) // 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程)
// 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT // 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT
val userJwtRaw = parsed.queryParameter("userJwt") val userJwtRaw = param("userJwt")
?: parsed.queryParameter("user_jwt") ?: param("user_jwt")
?: "" ?: ""
viewModelScope.launch { viewModelScope.launch {
val userJwt = runCatching { val userJwt = runCatching {
@ -214,18 +228,18 @@ class WebViewLoginViewModel @Inject constructor(
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull ?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull
?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull ?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull
?: parsed.queryParameter("refreshToken") ?: param("refreshToken")
?: parsed.queryParameter("refresh_token") ?: param("refresh_token")
?: parsed.queryParameter("data") ?: param("data")
val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull
?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull ?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull
?: parsed.queryParameter("clientId") ?: param("clientId")
?: parsed.queryParameter("client_id") ?: param("client_id")
?: "en1oxy7wnw8j9n" ?: "en1oxy7wnw8j9n"
// 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT // 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT
if (token.isNullOrBlank() && !refresh.isNullOrBlank()) { if (token.isNullOrBlank() && !refresh.isNullOrBlank()) {
val exchangeHost = parsed.queryParameter("host")?.takeIf { it.startsWith("http") } val exchangeHost = param("host")?.takeIf { it.startsWith("http") }
?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn" ?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn"
val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost) val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost)
if (exchanged != null) { if (exchanged != null) {
@ -248,7 +262,7 @@ class WebViewLoginViewModel @Inject constructor(
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。 // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
val deviceId = traeDeviceId() val deviceId = traeDeviceId()
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。 // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
val parsedUserId = parsed.queryParameter("userInfo")?.let { raw -> val parsedUserId = param("userInfo")?.let { raw ->
runCatching { runCatching {
val u = Json.parseToJsonElement(raw).jsonObject val u = Json.parseToJsonElement(raw).jsonObject
u["userId"]?.jsonPrimitive?.contentOrNull u["userId"]?.jsonPrimitive?.contentOrNull