fix(login): 页面加载反馈 + WB 凭证解析容错 + 系统浏览器登录入口
- 登录页新增加载进度条、加载失败提示和重试 - 顶部新增「在浏览器打开」:Trae 谷歌登录被 WebView 拦时可走系统浏览器(沿用本机 Chrome 的 Google 登录态) - WorkBuddy 轮询兼容 accessToken/access_token/token/authToken 等返回结构,失败时打印调试日志
This commit is contained in:
parent
b94e2f6994
commit
7574c3c7fb
@ -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 {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user