i18n 修复:消除英文环境下 5 处中文泄漏(最终审查 P1)

最终独立审查发现英文环境用户可见中文,全部修复:

- 激活成功弹窗:三个 parse 函数(OCGO/Codex/Ollama)改为返回结构化 TriggerSummary,
  中文摘要拼接移出 Repository;UI 层 buildTriggerSummaryText() 用资源组装本地化文本
- Codex 窗口标签:formatCodexPrimaryLabel / normalizeWindowLabel 增加「每周/每月」字面量分支,
  Dashboard 与 Widget 英文环境下正确显示 Weekly/Monthly
- Dashboard footer:DashboardCardUi.lastFetchError 改 UiText?,errorToUiText() 按类型映射资源
- 凭据编辑页:ParseError→error_parse_failed(RepositoryError.ParseError 新增公开 detail),
  「未知」「JSON格式错误」中文 fallback 改资源
- 服务详情页:triggerUsage else 分支复用 errorMessage() 全类型映射;
  TriggerError「token刷新失败」前缀资源化;「无响应体」不再展示
- 新增 TriggerSummary 数据类与 12 组中英资源(trigger_*/error_token_refresh_failed/等)
- 测试:CodexRepositoryTest 6 用例改为结构化断言,FormatUtilsTest 更新 monthly 映射;
  113 个用例全绿,lint 0 Error,资源 426/426 对齐
This commit is contained in:
WaterRain 2026-08-01 08:19:51 +00:00
parent 52b86e95e5
commit 6ac4d06e9f
No known key found for this signature in database
17 changed files with 263 additions and 105 deletions

View File

@ -3,6 +3,7 @@ package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -203,7 +204,7 @@ class CodexRepository(
* @param model 用户选择的模型 slug * @param model 用户选择的模型 slug
* @return Result.success(响应体原文) — 供 UI 展示 * @return Result.success(响应体原文) — 供 UI 展示
*/ */
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) { suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.CODEX) val credential = credentialRepository.get(ServiceType.CODEX)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据")) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
if (credential !is Credential.CodexCredential) if (credential !is Credential.CodexCredential)
@ -239,10 +240,10 @@ class CodexRepository(
DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}") DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}")
if (!resp2.isSuccessful) { if (!resp2.isSuccessful) {
DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}") DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}")
return@use Result.failure<String>( return@use Result.failure<TriggerSummary>(
TriggerError( TriggerError(
"HTTP ${resp2.code}", "HTTP ${resp2.code}",
body2.ifBlank { "无响应体" } body2.ifBlank { "" }
) )
) )
} }
@ -255,8 +256,8 @@ class CodexRepository(
} }
} }
} else { } else {
return@use Result.failure<String>( return@use Result.failure<TriggerSummary>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
) )
} }
} else { } else {
@ -400,7 +401,7 @@ class TriggerError(val summary: String, val responseBody: String) : Exception(su
* 将 SSE 流响应解析为简洁的文本摘要。 * 将 SSE 流响应解析为简洁的文本摘要。
* 提取:模型回复文本、用量统计(input/output tokens)。 * 提取:模型回复文本、用量统计(input/output tokens)。
*/ */
internal fun parseSseResponse(sseText: String, model: String): String { internal fun parseSseResponse(sseText: String, model: String): TriggerSummary {
val sseJson = Json { ignoreUnknownKeys = true } val sseJson = Json { ignoreUnknownKeys = true }
val outputText = StringBuilder() val outputText = StringBuilder()
var inputTokens: String? = null var inputTokens: String? = null
@ -429,16 +430,11 @@ internal fun parseSseResponse(sseText: String, model: String): String {
} catch (_: Exception) { } } catch (_: Exception) { }
} }
val sb = StringBuilder() return TriggerSummary(
sb.appendLine("✓ 激活成功") model = model,
sb.appendLine("模型: $model") reply = outputText.toString(),
if (responseId != null) sb.appendLine("Response ID: $responseId") inputTokens = inputTokens,
sb.appendLine() outputTokens = outputTokens,
sb.appendLine("回复:") responseId = responseId
sb.appendLine(outputText.ifEmpty { "(空)" }) )
if (inputTokens != null || outputTokens != null) {
sb.appendLine()
sb.appendLine("用量: input=$inputTokens output=$outputTokens tokens")
}
return sb.toString().trim()
} }

View File

@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -268,7 +269,7 @@ class OllamaRepository(
* @param model 用户选择的模型 slug * @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示 * @return Result.success(响应摘要文本) — 供 UI 展示
*/ */
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) { suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OLLAMA) val credential = credentialRepository.get(ServiceType.OLLAMA)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据")) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据"))
if (credential !is Credential.SessionCredential) if (credential !is Credential.SessionCredential)
@ -296,8 +297,8 @@ class OllamaRepository(
val bodyStr = resp.body?.string() ?: "" val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
return@withContext Result.failure<String>( return@withContext Result.failure<TriggerSummary>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
) )
} }
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
@ -317,7 +318,7 @@ class OllamaRepository(
/** /**
* 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。 * 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。
*/ */
internal fun parseOllamaChatResponse(responseBody: String, model: String): String { internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary {
val json = Json { ignoreUnknownKeys = true } val json = Json { ignoreUnknownKeys = true }
return try { return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject val root = json.parseToJsonElement(responseBody) as? JsonObject
@ -330,18 +331,14 @@ internal fun parseOllamaChatResponse(responseBody: String, model: String): Strin
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
val sb = StringBuilder() TriggerSummary(
sb.appendLine("✓ 激活成功") model = model,
sb.appendLine("模型: $model") reply = content,
sb.appendLine() inputTokens = promptTokens,
sb.appendLine("回复:") outputTokens = completionTokens,
sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") totalTokens = totalTokens
if (promptTokens != null || completionTokens != null) { )
sb.appendLine()
sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
}
sb.toString().trim()
} catch (_: Exception) { } catch (_: Exception) {
"✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" TriggerSummary(model = model, reply = null, parseFailed = true)
} }
} }

View File

@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -178,7 +179,7 @@ class OpenCodeGoRepository(
* @param model 用户选择的模型 slug * @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示 * @return Result.success(响应摘要文本) — 供 UI 展示
*/ */
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) { suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO) val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据")) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据"))
if (credential !is Credential.SessionCredential) if (credential !is Credential.SessionCredential)
@ -206,8 +207,8 @@ class OpenCodeGoRepository(
val bodyStr = resp.body?.string() ?: "" val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
return@withContext Result.failure<String>( return@withContext Result.failure<TriggerSummary>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
) )
} }
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
@ -318,7 +319,7 @@ class OpenCodeGoRepository(
/** /**
* 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。 * 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
*/ */
internal fun parseChatResponse(responseBody: String, model: String): String { internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary {
val json = Json { ignoreUnknownKeys = true } val json = Json { ignoreUnknownKeys = true }
return try { return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject val root = json.parseToJsonElement(responseBody) as? JsonObject
@ -331,18 +332,14 @@ internal fun parseChatResponse(responseBody: String, model: String): String {
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
val sb = StringBuilder() TriggerSummary(
sb.appendLine("✓ 激活成功") model = model,
sb.appendLine("模型: $model") reply = content,
sb.appendLine() inputTokens = promptTokens,
sb.appendLine("回复:") outputTokens = completionTokens,
sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") totalTokens = totalTokens
if (promptTokens != null || completionTokens != null) { )
sb.appendLine()
sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
}
sb.toString().trim()
} catch (_: Exception) { } catch (_: Exception) {
"✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" TriggerSummary(model = model, reply = null, parseFailed = true)
} }
} }

View File

@ -23,9 +23,9 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
class ServerError(val code: Int, cause: Throwable? = null) : class ServerError(val code: Int, cause: Throwable? = null) :
RepositoryError("服务端异常 (HTTP $code)", cause) RepositoryError("服务端异常 (HTTP $code)", cause)
/** 解析失败(HTML 解析结果为空 / JSON 反序列化失败) */ /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因 */
class ParseError(message: String, cause: Throwable? = null) : class ParseError(val detail: String, cause: Throwable? = null) :
RepositoryError("解析失败: $message", cause) RepositoryError("解析失败: $detail", cause)
/** 未知错误 */ /** 未知错误 */
class Unknown(cause: Throwable? = null) : RepositoryError( class Unknown(cause: Throwable? = null) : RepositoryError(

View File

@ -0,0 +1,24 @@
package com.rainy.token.domain.model
/**
* 一键激活用量的结构化响应摘要。
*
* Repository 只负责从响应中提取数据,不拼接展示文案;
* UI 层按当前语言环境用 stringResource 组装本地化文本。
*/
data class TriggerSummary(
/** 发起请求时使用的模型 */
val model: String,
/** 模型回复文本;null 表示响应中不存在回复内容 */
val reply: String?,
/** 输入 token 数(OCGO/Ollama 为 prompt_tokens,Codex 为 input_tokens) */
val inputTokens: String? = null,
/** 输出 token 数(OCGO/Ollama 为 completion_tokens,Codex 为 output_tokens) */
val outputTokens: String? = null,
/** 总 token 数(OCGO/Ollama 的 total_tokens;Codex 无此字段) */
val totalTokens: String? = null,
/** Codex 响应 ID */
val responseId: String? = null,
/** 响应解析失败,但请求已成功发出 */
val parseFailed: Boolean = false
)

View File

@ -10,6 +10,7 @@ import com.rainy.token.data.repository.RefreshWriteSession
import com.rainy.token.data.repository.RepositoryError import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.retryOnTransientError import com.rainy.token.data.repository.retryOnTransientError
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@ -55,7 +56,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchCodexModels(): Result<List<String>> = suspend fun fetchCodexModels(): Result<List<String>> =
codexRepositoryProvider.get().fetchModels() codexRepositoryProvider.get().fetchModels()
suspend fun triggerCodexUsage(model: String): Result<String> = suspend fun triggerCodexUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.CODEX) { withCredentialSession(ServiceType.CODEX) {
codexRepositoryProvider.get().triggerUsage(model) codexRepositoryProvider.get().triggerUsage(model)
} }
@ -63,7 +64,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchOpenCodeGoModels(): Result<List<String>> = suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
openCodeGoRepositoryProvider.get().fetchModels() openCodeGoRepositoryProvider.get().fetchModels()
suspend fun triggerOpenCodeGoUsage(model: String): Result<String> = suspend fun triggerOpenCodeGoUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.OPENCODE_GO) { withCredentialSession(ServiceType.OPENCODE_GO) {
openCodeGoRepositoryProvider.get().triggerUsage(model) openCodeGoRepositoryProvider.get().triggerUsage(model)
} }
@ -71,7 +72,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchOllamaModels(): Result<List<String>> = suspend fun fetchOllamaModels(): Result<List<String>> =
ollamaRepositoryProvider.get().fetchModels() ollamaRepositoryProvider.get().fetchModels()
suspend fun triggerOllamaUsage(model: String): Result<String> = suspend fun triggerOllamaUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.OLLAMA) { withCredentialSession(ServiceType.OLLAMA) {
ollamaRepositoryProvider.get().triggerUsage(model) ollamaRepositoryProvider.get().triggerUsage(model)
} }

View File

@ -56,8 +56,13 @@ fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): S
} }
} }
/** 标准化窗口标签:weekly → 每周,其他原样返回 */ /** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */
fun normalizeWindowLabel(label: String, weeklyLabel: String = "每周"): String = when (label.lowercase()) { fun normalizeWindowLabel(
"weekly" -> weeklyLabel label: String,
weeklyLabel: String = "每周",
monthlyLabel: String = "每月"
): String = when (label.lowercase()) {
"weekly", "每周" -> weeklyLabel
"monthly", "每月" -> monthlyLabel
else -> label else -> label
} }

View File

@ -10,6 +10,8 @@ import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase import com.rainy.token.domain.usecase.RefreshBalanceUseCase
import com.rainy.token.R
import com.rainy.token.ui.components.UiText
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -127,10 +129,10 @@ class DashboardViewModel @Inject constructor(
val cards = ServiceType.entries.map { type -> val cards = ServiceType.entries.map { type ->
val result = results[type] val result = results[type]
val error = result?.exceptionOrNull() val error = result?.exceptionOrNull()
val errMsg = error val errorUi = error
?.takeUnless { it is RepositoryError.CredentialChanged } ?.takeUnless { it is RepositoryError.CredentialChanged }
?.message ?.let { errorToUiText(it) }
buildCard(localStates.getValue(type), lastFetchError = errMsg) buildCard(localStates.getValue(type), lastFetchError = errorUi)
} }
_uiState.update { it.copy(refreshing = false, cards = cards) } _uiState.update { it.copy(refreshing = false, cards = cards) }
// 刷新成功后更新桌面小组件 // 刷新成功后更新桌面小组件
@ -145,7 +147,7 @@ class DashboardViewModel @Inject constructor(
private fun buildCard( private fun buildCard(
local: CredentialRepository.LocalState, local: CredentialRepository.LocalState,
lastFetchError: String? lastFetchError: UiText?
): DashboardCardUi = DashboardCardUi( ): DashboardCardUi = DashboardCardUi(
service = local.status.service, service = local.status.service,
credentialState = local.status.state, credentialState = local.status.state,
@ -181,8 +183,29 @@ data class DashboardCardUi(
val credentialState: CredentialStatus.State, val credentialState: CredentialStatus.State,
val credentialFingerprint: String?, val credentialFingerprint: String?,
val cachedBalance: CachedBalance?, val cachedBalance: CachedBalance?,
val lastFetchError: String? val lastFetchError: UiText?
) { ) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance val displayBalance: ServiceBalance? get() = cachedBalance?.balance
} }
/** 把 Repository 错误映射为本地化文案(错误详情 detail 为技术信息,随资源参数展示)。 */
private fun errorToUiText(error: Throwable): UiText = when (error) {
is RepositoryError.InvalidCredential ->
UiText.Resource(R.string.error_credential_invalid_reconfigure)
is RepositoryError.RateLimited -> UiText.Resource(
R.string.error_rate_limited_retry,
listOf(
error.retryAfterSeconds?.let {
UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
} ?: ""
)
)
is RepositoryError.Network -> UiText.Resource(R.string.error_network_check)
is RepositoryError.ServerError ->
UiText.Resource(R.string.error_server_http, listOf(error.code))
is RepositoryError.ParseError ->
UiText.Resource(R.string.error_parse_failed, listOf(error.detail))
else -> error.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}

View File

@ -279,11 +279,11 @@ internal fun formatCodexPrimaryLabel(
usageLabel: String = "用量" usageLabel: String = "用量"
): String = when (raw?.lowercase()) { ): String = when (raw?.lowercase()) {
"5h" -> "5h" "5h" -> "5h"
"7d", weeklyLabel -> weeklyLabel "7d", "每周", weeklyLabel -> weeklyLabel
"30d", monthlyLabel -> monthlyLabel "30d", "每月", monthlyLabel -> monthlyLabel
"usage" -> usageLabel "usage" -> usageLabel
null -> "5h" null -> "5h"
else -> raw else -> raw ?: "5h"
} }
@Composable @Composable
@ -295,8 +295,13 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
.maxOrNull()?.plus(1) ?: 0 .maxOrNull()?.plus(1) ?: 0
val weeklyLabel = stringResource(R.string.window_every_week) val weeklyLabel = stringResource(R.string.window_every_week)
val monthlyLabel = stringResource(R.string.window_every_month)
val windows = (0 until windowCount).map { i -> val windows = (0 until windowCount).map { i ->
val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage", weeklyLabel = weeklyLabel) val label = normalizeWindowLabel(
extras["window_$i.label"] ?: "Usage",
weeklyLabel = weeklyLabel,
monthlyLabel = monthlyLabel
)
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
Triple(label, remainingPct, resetAt) Triple(label, remainingPct, resetAt)
@ -526,12 +531,20 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service)
/** /**
* 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。 * 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。
* 错误分支直接透传 Repository 中文 message(Dynamic,不参与翻译)。 * 错误信息由 ViewModel 按 RepositoryError 类型映射为本地化资源。
*/ */
/** 截断 UiText 到最长 60 字符(仅对 Dynamic 原始文本生效;Resource 长度由资源控制)。 */
private fun truncateUiText(text: UiText): UiText = when (text) {
is UiText.Dynamic -> UiText.Dynamic(text.value.take(60) + if (text.value.length > 60) "…" else "")
else -> text
}
internal fun footerText(card: DashboardCardUi): UiText { internal fun footerText(card: DashboardCardUi): UiText {
if (card.lastFetchError != null) { val error = card.lastFetchError
val msg = card.lastFetchError.take(60) if (error != null) {
return UiText.Dynamic("⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}") // 截断保护:错误信息最长展示 60 字符(以 UiText 形式保留,UI 层按当前语言解析)
return UiText.Resource(R.string.dashboard_error_prefix, listOf(truncateUiText(error)))
} }
val fetchedAt = card.cachedBalance?.fetchedAt val fetchedAt = card.cachedBalance?.fetchedAt
?: return UiText.Resource(R.string.footer_never_fetched) ?: return UiText.Resource(R.string.footer_never_fetched)

View File

@ -61,6 +61,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.FetchMethod import com.rainy.token.domain.service.FetchMethod
import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
@ -218,7 +219,7 @@ fun ServiceDetailScreen(
when (triggerState) { when (triggerState) {
is TriggerState.Success -> ResponseDialog( is TriggerState.Success -> ResponseDialog(
title = stringResource(R.string.trigger_success_title), title = stringResource(R.string.trigger_success_title),
responseBody = (triggerState as TriggerState.Success).responseBody, responseBody = buildTriggerSummaryText((triggerState as TriggerState.Success).summary),
isError = false, isError = false,
onDismiss = { viewModel.dismissTrigger() } onDismiss = { viewModel.dismissTrigger() }
) )
@ -1122,6 +1123,43 @@ private fun ModelDropdown(
// ── Codex 一键激活用量:响应弹窗 ── // ── Codex 一键激活用量:响应弹窗 ──
/** 把结构化的激活摘要组装为本地化文本(Repository 不再拼接展示文案)。 */
@Composable
private fun buildTriggerSummaryText(summary: TriggerSummary): String = buildString {
appendLine(stringResource(R.string.trigger_success_header))
appendLine(stringResource(R.string.trigger_success_model, summary.model))
summary.responseId?.let { appendLine(stringResource(R.string.trigger_success_response_id, it)) }
appendLine()
appendLine(stringResource(R.string.trigger_reply_header))
if (summary.parseFailed) {
appendLine(stringResource(R.string.trigger_parse_failed))
} else {
appendLine(
summary.reply?.ifEmpty { stringResource(R.string.trigger_reply_empty) }
?: stringResource(R.string.trigger_reply_missing)
)
}
if (summary.inputTokens != null || summary.outputTokens != null) {
appendLine()
appendLine(
if (summary.totalTokens != null) {
stringResource(
R.string.trigger_usage_prompt_completion_total,
summary.inputTokens ?: "—",
summary.outputTokens ?: "—",
summary.totalTokens ?: "—"
)
} else {
stringResource(
R.string.trigger_usage_input_output,
summary.inputTokens ?: "—",
summary.outputTokens ?: "—"
)
}
)
}
}.trim()
@Composable @Composable
private fun ResponseDialog( private fun ResponseDialog(
title: String, title: String,

View File

@ -11,6 +11,7 @@ import com.rainy.token.data.repository.TriggerError
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import com.rainy.token.domain.usecase.RefreshBalanceUseCase import com.rainy.token.domain.usecase.RefreshBalanceUseCase
@ -324,8 +325,8 @@ class ServiceDetailViewModel @Inject constructor(
if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch
result result
.onSuccess { responseBody -> .onSuccess { summary ->
_triggerState.value = TriggerState.Success(responseBody) _triggerState.value = TriggerState.Success(summary)
kotlinx.coroutines.delay(2000) kotlinx.coroutines.delay(2000)
if (serviceGen == serviceGeneration && _serviceType.value == service) { if (serviceGen == serviceGeneration && _serviceType.value == service) {
refresh() refresh()
@ -336,7 +337,15 @@ class ServiceDetailViewModel @Inject constructor(
val responseBody: String? val responseBody: String?
when (error) { when (error) {
is TriggerError -> { is TriggerError -> {
message = UiText.Dynamic(error.summary) val prefix = "token 刷新失败: "
message = if (error.summary.startsWith(prefix)) {
UiText.Resource(
R.string.error_token_refresh_failed,
listOf(error.summary.removePrefix(prefix))
)
} else {
UiText.Dynamic(error.summary)
}
responseBody = error.responseBody.ifBlank { null } responseBody = error.responseBody.ifBlank { null }
} }
@ -359,8 +368,7 @@ class ServiceDetailViewModel @Inject constructor(
message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) { message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) {
UiText.Resource(R.string.error_trigger_not_supported) UiText.Resource(R.string.error_trigger_not_supported)
} else { } else {
error.message?.let { UiText.Dynamic(it) } errorMessage(error)
?: UiText.Resource(R.string.common_unknown)
} }
responseBody = null responseBody = null
} }
@ -529,7 +537,7 @@ class ServiceDetailViewModel @Inject constructor(
is RepositoryError.ServerError -> is RepositoryError.ServerError ->
UiText.Resource(R.string.error_server_http, listOf(error.code)) UiText.Resource(R.string.error_server_http, listOf(error.code))
is RepositoryError.ParseError -> is RepositoryError.ParseError ->
UiText.Resource(R.string.error_parse_failed, listOf(error.message ?: "")) UiText.Resource(R.string.error_parse_failed, listOf(error.detail))
else -> error.message?.let { UiText.Dynamic(it) } else -> error.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown) ?: UiText.Resource(R.string.common_unknown)
} }
@ -579,6 +587,6 @@ data class ServiceDetailUiState(
sealed class TriggerState { sealed class TriggerState {
data object Idle : TriggerState() data object Idle : TriggerState()
data object Loading : TriggerState() data object Loading : TriggerState()
data class Success(val responseBody: String) : TriggerState() data class Success(val summary: TriggerSummary) : TriggerState()
data class Error(val message: UiText, val responseBody: String?) : TriggerState() data class Error(val message: UiText, val responseBody: String?) : TriggerState()
} }

View File

@ -170,7 +170,15 @@ class CredentialEditViewModel @Inject constructor(
is RepositoryError.ServerError -> is RepositoryError.ServerError ->
UiText.Resource(R.string.error_server, listOf(err.code)) UiText.Resource(R.string.error_server, listOf(err.code))
is RepositoryError.Network -> is RepositoryError.Network ->
UiText.Resource(R.string.error_network, listOf(err.cause?.message ?: "未知")) UiText.Resource(
R.string.error_network,
listOf(
err.cause?.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
)
)
is RepositoryError.ParseError ->
UiText.Resource(R.string.error_parse_failed, listOf(err.detail))
else -> err?.message?.let { UiText.Dynamic(it) } else -> err?.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown) ?: UiText.Resource(R.string.common_unknown)
} }
@ -371,7 +379,10 @@ class CredentialEditViewModel @Inject constructor(
it.copy( it.copy(
message = UiText.Resource( message = UiText.Resource(
R.string.error_auth_json_parse, R.string.error_auth_json_parse,
listOf(e.message ?: "JSON 格式错误") listOf(
e.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.error_json_format)
)
) )
) )
} }

View File

@ -193,7 +193,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
val weeklyLabel = context.getString(R.string.window_every_week) val weeklyLabel = context.getString(R.string.window_every_week)
val monthlyLabel = context.getString(R.string.window_every_month) val monthlyLabel = context.getString(R.string.window_every_month)
val windows = (0 until windowCount).map { index -> val windows = (0 until windowCount).map { index ->
val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage", weeklyLabel = weeklyLabel) val label = normalizeWindowLabel(
extras["window_$index.label"] ?: "Usage",
weeklyLabel = weeklyLabel,
monthlyLabel = monthlyLabel
)
val remaining = extras["window_$index.remainingPct"]?.toIntOrNull() val remaining = extras["window_$index.remainingPct"]?.toIntOrNull()
val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })

View File

@ -73,6 +73,7 @@
<string name="footer_hours_ago">Updated %1$d h ago</string> <string name="footer_hours_ago">Updated %1$d h ago</string>
<string name="footer_updated_at">Updated %1$s</string> <string name="footer_updated_at">Updated %1$s</string>
<string name="dashboard_drag_hint">💡 Long-press a card to reorder</string> <string name="dashboard_drag_hint">💡 Long-press a card to reorder</string>
<string name="dashboard_error_prefix">⚠ %1$s</string>
<string name="dashboard_footer_privacy">Stored locally &amp; encrypted, nothing is uploaded</string> <string name="dashboard_footer_privacy">Stored locally &amp; encrypted, nothing is uploaded</string>
<string name="dialog_add_widget_title">Add home screen widget?</string> <string name="dialog_add_widget_title">Add home screen widget?</string>
<string name="dialog_add_widget_body">This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog.</string> <string name="dialog_add_widget_body">This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog.</string>
@ -214,6 +215,7 @@
<string name="msg_connect_success_balance">Connected! Balance: %1$s %2$s</string> <string name="msg_connect_success_balance">Connected! Balance: %1$s %2$s</string>
<string name="msg_connect_success_saved">Connected, credentials saved</string> <string name="msg_connect_success_saved">Connected, credentials saved</string>
<string name="error_api_key_empty">API Key cannot be empty</string> <string name="error_api_key_empty">API Key cannot be empty</string>
<string name="error_json_format">Invalid JSON format</string>
<string name="error_credential_rejected">Service rejected this credential (401/403)</string> <string name="error_credential_rejected">Service rejected this credential (401/403)</string>
<string name="error_credential_changed_test">Credentials changed during test</string> <string name="error_credential_changed_test">Credentials changed during test</string>
<string name="error_rate_limited">Too many requests (429)</string> <string name="error_rate_limited">Too many requests (429)</string>
@ -248,9 +250,19 @@
<!-- ══════════ 触发用量 ══════════ --> <!-- ══════════ 触发用量 ══════════ -->
<string name="trigger_success_title">✓ Usage activated</string> <string name="trigger_success_title">✓ Usage activated</string>
<string name="trigger_success_header">✓ Activation successful</string>
<string name="trigger_result_title">Request result</string> <string name="trigger_result_title">Request result</string>
<string name="trigger_error_prefix">Error: %1$s</string> <string name="trigger_error_prefix">Error: %1$s</string>
<string name="trigger_response_body">Response body:\n%1$s</string> <string name="trigger_response_body">Response body:\n%1$s</string>
<string name="trigger_success_model">Model: %1$s</string>
<string name="trigger_success_response_id">Response ID: %1$s</string>
<string name="trigger_reply_header">Reply:</string>
<string name="trigger_reply_empty">(empty)</string>
<string name="trigger_reply_missing">(no reply content)</string>
<string name="trigger_parse_failed">(response parsing failed, but the request was sent)</string>
<string name="trigger_usage_prompt_completion_total">Usage: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">Usage: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_failed">Token refresh failed: %1$s</string>
<!-- ══════════ WebView 登录 ══════════ --> <!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">Sign in to %1$s</string> <string name="title_webview_login">Sign in to %1$s</string>

View File

@ -76,6 +76,7 @@
<string name="footer_hours_ago">%1$d 小时前更新</string> <string name="footer_hours_ago">%1$d 小时前更新</string>
<string name="footer_updated_at">%1$s 更新</string> <string name="footer_updated_at">%1$s 更新</string>
<string name="dashboard_drag_hint">💡 长按卡片可拖拽排序</string> <string name="dashboard_drag_hint">💡 长按卡片可拖拽排序</string>
<string name="dashboard_error_prefix">⚠ %1$s</string>
<string name="dashboard_footer_version" translatable="false">RainyToken · v1.0</string> <string name="dashboard_footer_version" translatable="false">RainyToken · v1.0</string>
<string name="dashboard_footer_privacy">本地加密存储,不上传任何数据</string> <string name="dashboard_footer_privacy">本地加密存储,不上传任何数据</string>
<string name="dialog_add_widget_title">添加桌面小组件?</string> <string name="dialog_add_widget_title">添加桌面小组件?</string>
@ -218,6 +219,7 @@
<string name="msg_connect_success_balance">连接成功!余额: %1$s %2$s</string> <string name="msg_connect_success_balance">连接成功!余额: %1$s %2$s</string>
<string name="msg_connect_success_saved">连接成功,凭据已保存</string> <string name="msg_connect_success_saved">连接成功,凭据已保存</string>
<string name="error_api_key_empty">API Key 不能为空</string> <string name="error_api_key_empty">API Key 不能为空</string>
<string name="error_json_format">JSON 格式错误</string>
<string name="error_credential_rejected">服务拒绝该凭据 (401/403)</string> <string name="error_credential_rejected">服务拒绝该凭据 (401/403)</string>
<string name="error_credential_changed_test">测试期间凭据已变更</string> <string name="error_credential_changed_test">测试期间凭据已变更</string>
<string name="error_rate_limited">请求过于频繁 (429)</string> <string name="error_rate_limited">请求过于频繁 (429)</string>
@ -252,9 +254,19 @@
<!-- ══════════ 触发用量 ══════════ --> <!-- ══════════ 触发用量 ══════════ -->
<string name="trigger_success_title">✓ 用量已激活</string> <string name="trigger_success_title">✓ 用量已激活</string>
<string name="trigger_success_header">✓ 激活成功</string>
<string name="trigger_result_title">请求结果</string> <string name="trigger_result_title">请求结果</string>
<string name="trigger_error_prefix">错误: %1$s</string> <string name="trigger_error_prefix">错误: %1$s</string>
<string name="trigger_response_body">响应内容:\n%1$s</string> <string name="trigger_response_body">响应内容:\n%1$s</string>
<string name="trigger_success_model">模型: %1$s</string>
<string name="trigger_success_response_id">响应 ID: %1$s</string>
<string name="trigger_reply_header">回复:</string>
<string name="trigger_reply_empty">(空)</string>
<string name="trigger_reply_missing">(无回复内容)</string>
<string name="trigger_parse_failed">(响应解析失败,但请求已发出)</string>
<string name="trigger_usage_prompt_completion_total">用量: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">用量: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_failed">token 刷新失败:%1$s</string>
<!-- ══════════ WebView 登录 ══════════ --> <!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">登录 %1$s</string> <string name="title_webview_login">登录 %1$s</string>

View File

@ -4,6 +4,8 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -236,11 +238,12 @@ class CodexRepositoryTest {
""".trimIndent() """.trimIndent()
val result = parseSseResponse(sse, "gpt-5.6") val result = parseSseResponse(sse, "gpt-5.6")
assertTrue(result.contains("Hello, world!")) assertEquals("Hello, world!", result.reply)
assertTrue(result.contains("input=100")) assertEquals("100", result.inputTokens)
assertTrue(result.contains("output=50")) assertEquals("50", result.outputTokens)
assertTrue(result.contains("resp_123")) assertEquals("resp_123", result.responseId)
assertTrue(result.contains("gpt-5.6")) assertEquals("gpt-5.6", result.model)
assertFalse(result.parseFailed)
} }
@Test @Test
@ -251,9 +254,10 @@ class CodexRepositoryTest {
""".trimIndent() """.trimIndent()
val result = parseSseResponse(sse, "o4-mini") val result = parseSseResponse(sse, "o4-mini")
assertTrue(result.contains("(空)")) assertEquals("", result.reply)
assertTrue(result.contains("input=10")) assertEquals("10", result.inputTokens)
assertTrue(result.contains("output=5")) assertEquals("5", result.outputTokens)
assertNull(result.totalTokens)
} }
@Test @Test
@ -265,16 +269,18 @@ class CodexRepositoryTest {
""".trimIndent() """.trimIndent()
val result = parseSseResponse(sse, "gpt-5.6") val result = parseSseResponse(sse, "gpt-5.6")
assertTrue(result.contains("Hi there")) assertEquals("Hi there", result.reply)
// Should not contain usage line when no usage data // Should be null when no usage data present
assertTrue(!result.contains("用量:")) assertNull(result.inputTokens)
assertNull(result.outputTokens)
} }
@Test @Test
fun `parseSseResponse handles empty input`() { fun `parseSseResponse handles empty input`() {
val result = parseSseResponse("", "gpt-5.6") val result = parseSseResponse("", "gpt-5.6")
assertTrue(result.contains("(空)")) assertEquals("", result.reply)
assertTrue(result.contains("gpt-5.6")) assertEquals("gpt-5.6", result.model)
assertNull(result.responseId)
} }
@Test @Test
@ -288,9 +294,9 @@ class CodexRepositoryTest {
""".trimIndent() """.trimIndent()
val result = parseSseResponse(sse, "o4-mini") val result = parseSseResponse(sse, "o4-mini")
assertTrue(result.contains("Test reply")) assertEquals("Test reply", result.reply)
assertTrue(result.contains("input=200")) assertEquals("200", result.inputTokens)
assertTrue(result.contains("output=100")) assertEquals("100", result.outputTokens)
} }
@Test @Test
@ -304,18 +310,22 @@ class CodexRepositoryTest {
val result = parseSseResponse(sse, "gpt-5.6") val result = parseSseResponse(sse, "gpt-5.6")
// Malformed line should be skipped, valid ones parsed // Malformed line should be skipped, valid ones parsed
assertTrue(result.contains("Good")) assertEquals("Good", result.reply)
assertTrue(result.contains("input=1")) assertEquals("1", result.inputTokens)
} }
@Test @Test
fun `parseSseResponse contains activation success header`() { fun `parseSseResponse returns structured metadata`() {
val sse = """ val sse = """
data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}} data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}}
data: [DONE] data: [DONE]
""".trimIndent() """.trimIndent()
val result = parseSseResponse(sse, "gpt-5.6") val result = parseSseResponse(sse, "gpt-5.6")
assertTrue(result.contains("✓ 激活成功")) assertEquals("r1", result.responseId)
assertEquals("0", result.inputTokens)
assertEquals("0", result.outputTokens)
assertEquals("", result.reply)
assertFalse(result.parseFailed)
} }
} }

View File

@ -112,12 +112,19 @@ class FormatUtilsTest {
assertEquals("每周", normalizeWindowLabel("weekly")) assertEquals("每周", normalizeWindowLabel("weekly"))
assertEquals("每周", normalizeWindowLabel("Weekly")) assertEquals("每周", normalizeWindowLabel("Weekly"))
assertEquals("每周", normalizeWindowLabel("WEEKLY")) assertEquals("每周", normalizeWindowLabel("WEEKLY"))
assertEquals("每周", normalizeWindowLabel("每周"))
} }
@Test @Test
fun `normalizeWindowLabel non-weekly passes through`() { fun `normalizeWindowLabel monthly maps to monthly label`() {
assertEquals("每月", normalizeWindowLabel("monthly"))
assertEquals("Monthly", normalizeWindowLabel("MONTHLY", monthlyLabel = "Monthly"))
assertEquals("每月", normalizeWindowLabel("每月"))
}
@Test
fun `normalizeWindowLabel unknown passes through`() {
assertEquals("5h", normalizeWindowLabel("5h")) assertEquals("5h", normalizeWindowLabel("5h"))
assertEquals("monthly", normalizeWindowLabel("monthly"))
assertEquals("30d", normalizeWindowLabel("30d")) assertEquals("30d", normalizeWindowLabel("30d"))
} }