fix(login): 页面加载反馈 + WB 凭证解析容错 + 系统浏览器登录入口

- 登录页新增加载进度条、加载失败提示和重试
- 顶部新增「在浏览器打开」:Trae 谷歌登录被 WebView 拦时可走系统浏览器(沿用本机 Chrome 的 Google 登录态)
- WorkBuddy 轮询兼容 accessToken/access_token/token/authToken 等返回结构,失败时打印调试日志
This commit is contained in:
Liuxinyu176 2026-10-09 16:32:36 +08:00
parent b94e2f6994
commit 7574c3c7fb
3 changed files with 106 additions and 14 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 = 30 versionCode = 31
versionName = "1.7.10" versionName = "1.7.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -1,5 +1,7 @@
package com.rainy.token.ui.webview package com.rainy.token.ui.webview
import android.content.Intent
import android.net.Uri
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
@ -10,12 +12,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -34,6 +39,7 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@ -70,7 +76,10 @@ fun WebViewLoginScreen(
} }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
var webViewGeneration by remember { mutableStateOf(0) } var webViewGeneration by remember { mutableStateOf(0) }
var pageLoading by remember { mutableStateOf(true) }
var pageError by remember { mutableStateOf<String?>(null) }
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填) // 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
LaunchedEffect(uiState.loginSucceeded) { LaunchedEffect(uiState.loginSucceeded) {
@ -104,6 +113,18 @@ fun WebViewLoginScreen(
} }
}, },
actions = { actions = {
TextButton(onClick = {
val url = uiState.loginUrl
if (url.isNotBlank()) {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}) {
Text(stringResource(R.string.action_open_login_entry))
}
TextButton(onClick = { TextButton(onClick = {
viewModel.clearWebViewSession() viewModel.clearWebViewSession()
webViewGeneration++ webViewGeneration++
@ -118,6 +139,9 @@ fun WebViewLoginScreen(
.fillMaxSize() .fillMaxSize()
.padding(innerPadding)) { .padding(innerPadding)) {
if (uiState.loginUrl.isNotEmpty()) { if (uiState.loginUrl.isNotEmpty()) {
if (pageLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
key(webViewGeneration) { key(webViewGeneration) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->
@ -176,6 +200,8 @@ fun WebViewLoginScreen(
} }
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
pageLoading = true
pageError = null
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}") DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}")
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
} }
@ -187,14 +213,59 @@ fun WebViewLoginScreen(
viewModel.onPageFinished(url) viewModel.onPageFinished(url)
DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}") DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}")
} }
override fun onReceivedError(
view: WebView?,
request: android.webkit.WebResourceRequest?,
error: android.webkit.WebResourceError?
) {
pageLoading = false
pageError = error?.description?.toString() ?: request?.url?.toString().orEmpty()
DebugLog.w("TraeLogin", "pageError: ${request?.url?.toString()?.toSafeLogUrl()} ${error?.description}")
super.onReceivedError(view, request, error)
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
pageLoading = newProgress < 100
super.onProgressChanged(view, newProgress)
}
} }
webChromeClient = WebChromeClient()
loadUrl(uiState.loginUrl) loadUrl(uiState.loginUrl)
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
pageError?.let { err ->
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Column(Modifier.padding(12.dp)) {
Text(
text = stringResource(R.string.error_network) + ": " + err,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
pageError = null
webViewGeneration++
}) {
Text(stringResource(R.string.action_retry))
}
TextButton(onClick = {
val url = uiState.loginUrl
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}) {
Text(stringResource(R.string.action_open_login_entry))
}
}
}
}
}
} else { } else {
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻) // loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
Box( Box(

View File

@ -273,6 +273,7 @@ class WebViewLoginViewModel @Inject constructor(
val state = data?.get("state")?.jsonPrimitive?.contentOrNull val state = data?.get("state")?.jsonPrimitive?.contentOrNull
val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull
?: data?.get("authURL")?.jsonPrimitive?.contentOrNull ?: data?.get("authURL")?.jsonPrimitive?.contentOrNull
DebugLog.i("WorkBuddyLogin", "bind: host=${workBuddyBase} bodyKeys=${json.keys} dataKeys=${data?.keys ?: emptySet()}")
if (state.isNullOrBlank() || authURL.isNullOrBlank()) { if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
_uiState.update { _uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic)) it.copy(error = UiText.Resource(R.string.error_network_generic))
@ -307,16 +308,34 @@ class WebViewLoginViewModel @Inject constructor(
resp.close() resp.close()
s s
} }
DebugLog.i("WorkBuddyLogin", "poll: state=${state.take(6)}… bodyLen=${body.length}")
val json = Json.parseToJsonElement(body).jsonObject val json = Json.parseToJsonElement(body).jsonObject
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1 val codeText = json["code"]?.jsonPrimitive?.contentOrNull
if (code == 0) { val code = codeText?.toIntOrNull() ?: json["code"]?.jsonPrimitive?.intOrNull ?: -1
val data = json["data"]?.jsonObject val rawData = json["data"]?.jsonObject ?: json.jsonObject
val accessToken = data?.get("accessToken")?.jsonPrimitive?.contentOrNull DebugLog.i(
?: data?.get("access_token")?.jsonPrimitive?.contentOrNull "WorkBuddyLogin",
val refreshToken = data?.get("refreshToken")?.jsonPrimitive?.contentOrNull "poll: code=$codeText bodyKeys=${json.keys} dataKeys=${rawData.keys}"
?: data?.get("refresh_token")?.jsonPrimitive?.contentOrNull )
val expiresIn = data?.get("expiresIn")?.jsonPrimitive?.longOrNull if (code == 0 || codeText == "0") {
?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L // 兼容多种字段:accessToken/access_token/token/authToken,refreshToken/refresh_token,expiresIn/expires_at
val accessToken = rawData.let { obj ->
obj["accessToken"]?.jsonPrimitive?.contentOrNull
?: obj["access_token"]?.jsonPrimitive?.contentOrNull
?: obj["token"]?.jsonPrimitive?.contentOrNull
?: obj["authToken"]?.jsonPrimitive?.contentOrNull
}
val refreshToken = rawData.let { obj ->
obj["refreshToken"]?.jsonPrimitive?.contentOrNull
?: obj["refresh_token"]?.jsonPrimitive?.contentOrNull
?: obj["refresh"]?.jsonPrimitive?.contentOrNull
}
val expiresIn = rawData.let { obj ->
obj["expiresIn"]?.jsonPrimitive?.longOrNull
?: obj["expires_in"]?.jsonPrimitive?.longOrNull
?: obj["expiresAt"]?.jsonPrimitive?.longOrNull
?: 7200L
}
if (!accessToken.isNullOrBlank()) { if (!accessToken.isNullOrBlank()) {
val expiresAt = System.currentTimeMillis() + expiresIn * 1000 val expiresAt = System.currentTimeMillis() + expiresIn * 1000
// 刚成功登录的凭据视为已验证(见 Trae 同款注释) // 刚成功登录的凭据视为已验证(见 Trae 同款注释)
@ -337,11 +356,13 @@ class WebViewLoginViewModel @Inject constructor(
loginAccountId = effectiveId.ifBlank { null } loginAccountId = effectiveId.ifBlank { null }
) )
} }
DebugLog.i("WorkBuddyLogin", "poll: 凭证已保存,登录完成")
return return
} }
DebugLog.w("WorkBuddyLogin", "poll: code=0 但 data 无 accessToken(dataKeys=${rawData.keys})")
} }
} catch (_: Exception) { } catch (e: Exception) {
// 网络异常时继续轮询,直到超时 DebugLog.w("WorkBuddyLogin", "poll: 异常=${e.message ?: "unknown"}")
} }
delay(2000) delay(2000)
} }