diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3596af6..104391d 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 = 34 - versionName = "1.7.14" + versionCode = 35 + versionName = "1.7.15" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt index d21c061..73055e7 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt @@ -190,8 +190,9 @@ fun WebViewLoginScreen( } fun handleOverrideUrl(url: String): Boolean { - // Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt - if (url.startsWith("http://127.0.0.1:18080/authorize")) { + // Trae OAuth 回调:拦截 127.0.0.1/localhost:18080/authorize,提取 userJwt + // (部分流程会从 https 跳到 http,或把参数放 fragment) + if (isTraeLoginCallback(url)) { DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}") viewModel.onTraeCallback(url) return true @@ -199,10 +200,24 @@ fun WebViewLoginScreen( 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?) { pageLoading = true pageError = null 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) } 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 eb6ce4a..f4e1679 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 @@ -191,17 +191,31 @@ class WebViewLoginViewModel @Inject constructor( if (service != ServiceType.TRAE) 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 { DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败") return } - // 只记录参数名(不含 token 值,避免把 token 明文落调试日志) - DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}") + // 只记录参数名(不含 token 值,避免把 token 明文落调试日志);query+both + DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,query=${callbackUri.query?.split("&")?.map { it.substringBefore("=") } ?: emptyList()} fragmentKeys=${callbackUri.fragment?.split("&")?.map { it.substringBefore("=") } ?: emptyList()}") // Trae 回调可能走两套: // 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程) // 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT - val userJwtRaw = parsed.queryParameter("userJwt") - ?: parsed.queryParameter("user_jwt") + val userJwtRaw = param("userJwt") + ?: param("user_jwt") ?: "" viewModelScope.launch { val userJwt = runCatching { @@ -214,18 +228,18 @@ class WebViewLoginViewModel @Inject constructor( ?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull ?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull - ?: parsed.queryParameter("refreshToken") - ?: parsed.queryParameter("refresh_token") - ?: parsed.queryParameter("data") + ?: param("refreshToken") + ?: param("refresh_token") + ?: param("data") val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull ?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull - ?: parsed.queryParameter("clientId") - ?: parsed.queryParameter("client_id") + ?: param("clientId") + ?: param("client_id") ?: "en1oxy7wnw8j9n" // 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT 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" val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost) if (exchanged != null) { @@ -248,7 +262,7 @@ class WebViewLoginViewModel @Inject constructor( // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。 val deviceId = traeDeviceId() // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。 - val parsedUserId = parsed.queryParameter("userInfo")?.let { raw -> + val parsedUserId = param("userInfo")?.let { raw -> runCatching { val u = Json.parseToJsonElement(raw).jsonObject u["userId"]?.jsonPrimitive?.contentOrNull