feat(sub2api): 未配置服务不再占位;面板管理入口改到卡片右下角;网页登录自动抓取凭据

1) 未配置的用量卡不再显示
   OpenCode/CommandCode 用量卡与 Token 热力图卡此前是无条件加入首页列表的,
   即使对应服务未配置凭据也会占位显示。现改为按 cards 里是否包含该服务来判断,
   热力图卡跟随 OCGO。

2) 面板管理入口位置:卡片底部右下角,与「刚刚更新」同一行右对齐
   (此前放在余额右侧,用户反馈位置不对)
   底部 Row:左 footerText(weight(1f, fill=false)) + 中间弹性 Spacer + 右按钮。

3) 网页登录后自动获取凭据
   - WebViewLoginScreen 注入 JS 桥(@JavascriptInterface LoginProbeBridge),
     页面加载完成后读取 localStorage.auth_token / auth_user / favicon / origin 回传。
   - WebViewSessionSaver.saveSub2ApiPanelSession 落库面板凭据,保留已有 email/password/apiKey。
   - 安全:每次会话随机 nonce(回传必须匹配)+ origin host 白名单(trustedHosts
     来自用户配置的面板基址),防止登录过程中跳转的第三方页面伪造 payload 投毒凭据。
   - 自托管实例的 baseUrl 由 ViewModel 自行从凭据仓库读取,避免每个导航调用点漏传。

4) 修复(独立审查发现)
   - saveSub2ApiPanelSession 去 runBlocking 改 suspend,在 viewModelScope 中调用,
     避免 JS 桥线程同步等待 Keystore 加解密 + DataStore 写盘。
   - 删除「用 origin 查 Cookie」的旁路:origin 无 path,CookieManager 查不到会误报
     「登录完成了吗」,Cookie 抓取统一交回 onPageFinished 的真实 URL。
   - normalizeBaseUrl 改用 okhttp3 HttpUrl 解析,垃圾串不再通过校验后落库。
   - 撤销给 DeepSeek 加 loginUrl 的改动:其凭据类型是 ApiKeyCredential,
     走 Cookie 路径会写成 SessionCredential 并导致 API Key 静默丢失。

真机验证(V2172A / Android 15):
首页只剩 Sub2API 一张卡;面板管理与「刚刚更新」同一行(均 y1492-1555,按钮右对齐 x783-980);
点按钮正常打开弹窗;网页登录容器正常加载面板首页(证明 baseUrl 兜底生效)。
compileDebugKotlin 与 lintDebug 均通过。
This commit is contained in:
Liuxinyu176 2026-09-12 10:57:58 +08:00
parent edc2a40dc1
commit 8592f15663
4 changed files with 284 additions and 52 deletions

View File

@ -1,6 +1,7 @@
package com.rainy.token.data.repository
import android.webkit.CookieManager
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import com.rainy.token.domain.model.CookieEntry
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
@ -48,6 +49,61 @@ class WebViewSessionSaver @Inject constructor(
return session
}
/**
* 面板登录成功后自动写入 Sub2API 凭据。
*
* 面板把登录态放在 localStorage.auth_token(JWT),所以 WebView 登录完成后
* 注入 JS 读取该键,连同站点基址一起落库,用户无需再手工粘贴。
*
* @param baseUrl 面板基址(登录页所在 origin,自动补 https 前缀)
* @param authToken localStorage.auth_token 的值;为空表示没抓到,返回 null 不覆盖旧凭据
* @param siteIcon 站点 Logo(可选,登录页 favicon/og:image)
* @param email 登录邮箱(可选,仅用于展示与回退登录)
*/
suspend fun saveSub2ApiPanelSession(
baseUrl: String,
authToken: String,
siteIcon: String? = null,
email: String? = null
): Credential.Sub2ApiCredential? {
val token = authToken.trim()
if (token.isEmpty()) return null
val base = normalizeBaseUrl(baseUrl) ?: return null
val existing = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential
val credential = Credential.Sub2ApiCredential(
service = ServiceType.SUB2API,
baseUrl = base,
email = email?.takeIf { it.isNotBlank() } ?: existing?.email.orEmpty(),
password = existing?.password.orEmpty(),
apiKey = existing?.apiKey,
authToken = token,
siteIcon = siteIcon?.takeIf { it.isNotBlank() } ?: existing?.siteIcon,
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(credential)
return credential
}
/**
* 归一化面板基址:补 scheme、去尾斜杠,只保留 scheme://host[:port]。
*
* 用 [okhttp3.HttpUrl] 真正解析而不是字符串长度判断——否则 "not a url" 这类垃圾
* 会被拼成 "https://not a url" 通过校验并落库,后续拼请求 URL 时 OkHttp 直接抛异常。
*/
private fun normalizeBaseUrl(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return null
val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://" + trimmed
}
val parsed = withScheme.toHttpUrlOrNull() ?: return null
// 只保留 origin,丢掉 path/query(面板基址就是 origin)
return parsed.newBuilder().encodedPath("/").query(null).fragment(null).build()
.toString().trimEnd('/')
}
/**
* 把 `name1=value1; name2=value2` 解析为 [CookieEntry] 列表。
* 完整属性(domain/path/expires/secure/httpOnly)暂不可用,因为 Android CookieManager

View File

@ -464,15 +464,27 @@ private fun rememberDashboardItems(
EmptyServicesHintCard(onOpenSettings = onOpenSettings)
})
}
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger)
})
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
})
add(DashboardHomeItem(HEATMAP_CARD_ID) {
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
})
// 用量卡与热力图卡只在对应服务已配置时出现:
// 未配置 OCGO/CCGO 时不应展示它们的用量卡(此前无条件添加,导致未配置也占位)。
val configuredServices = cards.map { it.service }.toSet()
val ocgoConfigured = ServiceType.OPENCODE_GO in configuredServices
val ccgoConfigured = ServiceType.COMMANDCODE_GO in configuredServices
if (ocgoConfigured) {
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger)
})
}
if (ccgoConfigured) {
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
})
}
// 热力图统计的是 OCGO 数据,未配置 OCGO 时同样不展示
if (ocgoConfigured) {
add(DashboardHomeItem(HEATMAP_CARD_ID) {
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
})
}
cards.forEach { card ->
add(DashboardHomeItem("service:${card.service.storageKey}") {
DashboardCard(
@ -783,31 +795,7 @@ private fun DashboardCard(
Spacer(modifier = Modifier.height(16.dp))
// ─── 主体:服务特定的主信息 ───
// Sub2API 把「面板管理」入口传入余额行,使其与余额数字齐平(见 Sub2ApiBalanceWithUsage)
val showPanelEntry = card.service == ServiceType.SUB2API &&
card.credentialState != CredentialStatus.State.NOT_CONFIGURED &&
onOpenPanelManagement != null
BalanceMainArea(
card = card,
balanceTrailing = if (showPanelEntry) {
{
TextButton(onClick = { onOpenPanelManagement!!.invoke() }) {
Text(
stringResource(R.string.sub2_panel_manage_title),
color = StrawberryPink,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink
)
}
}
} else null
)
BalanceMainArea(card)
// 用量服务卡片底部:用量详情入口 + Token 活动快捷入口(仅当有凭证时显示)
if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) {
@ -851,16 +839,47 @@ private fun DashboardCard(
}
}
// ─── 底部:更新时间 / 错误信息 ───
// ─── 底部:左侧更新时间/错误信息,右侧「面板管理」入口(同一行、右对齐)───
Spacer(modifier = Modifier.height(12.dp))
Text(
text = footerText(card).asString(),
style = MaterialTheme.typography.bodySmall,
color = if (card.lastFetchError != null)
MaterialTheme.colorScheme.error
else
inkMuted()
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = footerText(card).asString(),
style = MaterialTheme.typography.bodySmall,
color = if (card.lastFetchError != null)
MaterialTheme.colorScheme.error
else
inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
// 弹性间距:窄屏/英文长文案时优先压缩左侧文案,按钮保持右对齐不被折行
Spacer(modifier = Modifier.weight(1f))
if (card.service == ServiceType.SUB2API &&
card.credentialState != CredentialStatus.State.NOT_CONFIGURED &&
onOpenPanelManagement != null
) {
TextButton(
onClick = onOpenPanelManagement,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Text(
stringResource(R.string.sub2_panel_manage_title),
color = StrawberryPink,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}

View File

@ -49,9 +49,14 @@ fun WebViewLoginScreen(
service: ServiceType,
onBack: () -> Unit,
onLoginSucceeded: (ServiceType) -> Unit,
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
configuredBaseUrl: String? = null,
viewModel: WebViewLoginViewModel = hiltViewModel()
) {
LaunchedEffect(service) { viewModel.bind(service) }
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
LaunchedEffect(service, configuredBaseUrl) {
viewModel.bind(service, configuredBaseUrl)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 登录成功自动回调
@ -100,9 +105,21 @@ fun WebViewLoginScreen(
// 且 TLS fingerprinting 也会识别 WebView。此入口主要作为 fallback,
// 主流程走"手动粘贴 Cookie"路线。
// 注入 JS 桥:面板登录后把 localStorage 里的登录态回传原生层,
// 实现「网页登录一次即自动配置凭据」,无需手工粘贴 token
addJavascriptInterface(
LoginProbeBridge { json ->
viewModel.onLocalStorageProbed(parseProbeJson(json))
},
"AndroidBridge"
)
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
url?.let { viewModel.onPageFinished(it) }
url ?: return
// 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存
view?.evaluateJavascript(viewModel.probeScript, null)
viewModel.onPageFinished(url)
}
}
webChromeClient = WebChromeClient()
@ -147,3 +164,26 @@ fun WebViewLoginScreen(
)
}
}
/**
* JS → 原生桥:页面脚本通过 window.AndroidBridge.onProbe(json) 回传 localStorage 登录态。
*
* 用 @JavascriptInterface 注解的方法只允许传基本类型,这里统一走 JSON 字符串,
* 解析失败按空处理(页面脚本也可能被站点 CSP 拦掉)。
*/
private class LoginProbeBridge(private val onProbe: (String) -> Unit) {
@android.webkit.JavascriptInterface
fun onProbe(json: String) {
runCatching { onProbe(json) }
}
}
/** 解析探测脚本回传的 JSON(扁平 string→string 结构)。 */
private fun parseProbeJson(json: String): Map<String, String> = runCatching {
val obj = org.json.JSONObject(json)
buildMap {
obj.keys().forEach { k ->
val v = obj.optString(k, "")
if (v.isNotEmpty()) put(k, v)
}
}
}.getOrDefault(emptyMap())

View File

@ -1,6 +1,9 @@
package com.rainy.token.ui.webview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import com.rainy.token.R
import com.rainy.token.data.repository.WebViewSessionSaver
import com.rainy.token.domain.model.Credential
@ -22,19 +25,38 @@ import javax.inject.Inject
*/
@HiltViewModel
class WebViewLoginViewModel @Inject constructor(
private val sessionSaver: WebViewSessionSaver
private val sessionSaver: WebViewSessionSaver,
private val credentialRepository: com.rainy.token.data.repository.CredentialRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(WebViewLoginUiState())
val uiState: StateFlow<WebViewLoginUiState> = _uiState.asStateFlow()
fun bind(service: ServiceType) {
/**
* @param configuredBaseUrl 用户已保存的自托管实例基址(Sub2API 用)。
* 用于把「可信 host」限定为用户自己配置的域名,登录过程中跳转到的第三方页面
* 无法借此写入凭据。
*/
fun bind(service: ServiceType, configuredBaseUrl: String? = null) {
val config = ServiceConfigProvider.get(service)
_uiState.update {
it.copy(
service = service,
loginUrl = config.loginUrl
)
it.copy(service = service, loginUrl = config.loginUrl, trustedHosts = emptySet())
}
// Sub2API 是自托管实例:loginUrl 为空,登录入口与可信 host 都来自用户已保存的 baseUrl。
// 这里自己从凭据仓库取,避免每个导航调用点都要传一遍(漏传会直接导致登录页打不开)。
viewModelScope.launch {
val base = configuredBaseUrl
?: (credentialRepository.get(service) as? Credential.Sub2ApiCredential)?.baseUrl
val hosts = buildSet {
base?.toHttpUrlOrNull()?.host?.let { add(it) }
config.loginUrl.toHttpUrlOrNull()?.host?.let { add(it) }
}
_uiState.update {
it.copy(
loginUrl = config.loginUrl.ifBlank { base.orEmpty() },
trustedHosts = hosts
)
}
}
}
@ -63,6 +85,66 @@ class WebViewLoginViewModel @Inject constructor(
_uiState.update { it.copy(pendingManualConfirm = false) }
}
/**
* 面板登录成功后由 WebView 注入 JS 回调,把 localStorage 里的凭据回传。
*
* Sub2API 面板把登录态放在 localStorage.auth_token(JWT);抓到即代表登录成功,
* 无需用户在设置页手工粘贴 token。
*/
fun onLocalStorageProbed(values: Map<String, String>) {
val service = _uiState.value.service ?: return
if (_uiState.value.loginSucceeded) return
// nonce 校验:拒绝非本次注入脚本发出的调用(防第三方页面伪造)
if (!isValidProbe(values[SUB2_NONCE_KEY])) return
// 只有自托管面板走 localStorage 抓取;其余服务的登录态在 Cookie 里,
// 由 onPageFinished(真实带 path 的 URL) 负责,避免用 origin 查 Cookie 查不到而误报。
if (service != ServiceType.SUB2API) return
val token = values[SUB2_AUTH_TOKEN_KEY].orEmpty()
if (token.isBlank()) return
val origin = values[SUB2_ORIGIN_KEY].orEmpty()
// 防凭据投毒:登录容器会加载第三方页面(OAuth / CDN),它们同样能调到 JS 桥。
// 只有 origin 与用户配置的面板基址同源时才接受,避免被伪造的 auth_token 覆盖凭据。
if (!isTrustedOrigin(origin)) return
viewModelScope.launch {
val saved = sessionSaver.saveSub2ApiPanelSession(
baseUrl = origin.ifBlank { _uiState.value.loginUrl },
authToken = token,
siteIcon = values[SUB2_ICON_KEY],
email = values[SUB2_EMAIL_KEY]
)
if (saved != null) {
_uiState.update {
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null)
}
}
}
}
/**
* origin 是否可信:必须与用户填写/已知的面板基址同 host。
* 登录页会跳转到第三方域(OAuth、CDN),那些页面注入的脚本不应改写本地凭据。
*/
private fun isTrustedOrigin(origin: String): Boolean {
val probeHost = origin.toHttpUrlOrNull()?.host ?: return false
val allowed = _uiState.value.trustedHosts
if (allowed.isEmpty()) return false
return allowed.any { it.equals(probeHost, ignoreCase = true) }
}
/**
* 每次会话的随机串,随探测脚本一起注入;回传时必须带上它才被接受。
* 登录容器会加载第三方页面(OAuth / CDN),仅靠「origin 同源校验」不足以防止
* 它们直接调用 JS 桥伪造 payload,nonce 让伪造者无法构造合法调用。
*/
private val probeNonce: String = java.util.UUID.randomUUID().toString().replace("-", "")
/** 探测脚本(注入 nonce,回传时会带回来校验)。 */
val probeScript: String get() = buildProbeScript(probeNonce)
/** 校验回传是否来自本次注入的脚本。 */
fun isValidProbe(nonce: String?): Boolean =
nonce != null && nonce.isNotEmpty() && nonce == probeNonce
private fun saveSession(url: String) {
val service = _uiState.value.service ?: return
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
@ -92,9 +174,44 @@ class WebViewLoginViewModel @Inject constructor(
}
}
/** 面板 localStorage 中承载登录态的键(与网页端一致)。 */
private const val SUB2_AUTH_TOKEN_KEY = "auth_token"
private const val SUB2_ORIGIN_KEY = "origin"
private const val SUB2_ICON_KEY = "site_icon"
private const val SUB2_EMAIL_KEY = "email"
private const val SUB2_NONCE_KEY = "nonce"
/**
* 在页面里读取面板登录态并回传。
* 通过 window.AndroidBridge(由 WebView 注入)回调,避免 evaluateJavascript 的返回值在
* onPageFinished 时机不可靠的问题。
*/
internal fun buildProbeScript(nonce: String): String = """
(function () {
try {
var out = {};
var t = window.localStorage.getItem('auth_token');
if (t) out.auth_token = t;
var u = window.localStorage.getItem('auth_user');
if (u) {
try { out.email = (JSON.parse(u) || {}).email || ''; } catch (e) {}
}
var icon = document.querySelector('link[rel~="icon"]');
if (icon && icon.href) out.site_icon = icon.href;
out.origin = window.location.origin;
out.nonce = '$nonce';
if (window.AndroidBridge && window.AndroidBridge.onProbe) {
window.AndroidBridge.onProbe(JSON.stringify(out));
}
} catch (e) {}
})();
"""
data class WebViewLoginUiState(
val service: ServiceType? = null,
val loginUrl: String = "",
/** 允许通过 JS 桥写入凭据的 host 白名单(来自用户配置的面板基址 / 登录 URL)。 */
val trustedHosts: Set<String> = emptySet(),
val loginSucceeded: Boolean = false,
val savedSession: Credential.SessionCredential? = null,
val pendingManualConfirm: Boolean = false,