diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt
index 7c0dd4c..c775f51 100644
--- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt
@@ -115,9 +115,9 @@ class CodexRepository(
parseUsageWindows(usageResult)
} catch (e: Exception) {
DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}")
- return@withContext Result.failure(RepositoryError.ParseError("Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}"))
}
- if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据"))
+ if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据"))
val config = ServiceConfigProvider.get(ServiceType.CODEX)
val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first()
@@ -155,16 +155,16 @@ class CodexRepository(
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
- val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
// 结构: { "openai": { "models": { "gpt-5.6": {...}, ... } } }
val openaiProvider = root["openai"] as? JsonObject
val modelsObj = openaiProvider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
- ?: throw RepositoryError.ParseError("未找到 OpenAI 模型列表")
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenAI 模型列表")
}
if (models.isEmpty()) {
- return@withContext Result.failure(RepositoryError.ParseError("模型列表为空"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
@@ -386,8 +386,8 @@ class CodexRepository(
DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code)
}
- return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject)
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject)
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
}
}
diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt
index 12ea98b..2ae118c 100644
--- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt
@@ -106,7 +106,7 @@ class CommandCodeUsageRepository(
}
if (body == null) return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val records = parseUsageResponse(body)
diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt
index 04a45f8..a401d65 100644
--- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt
@@ -178,13 +178,14 @@ class OllamaRepository(
}
val html = resp.body?.string() ?: return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val parsed = parseUsage(html)
if (parsed == null) {
return@withContext Result.failure(
RepositoryError.ParseError(
+ RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。"
)
)
@@ -241,15 +242,15 @@ class OllamaRepository(
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
- val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
val provider = root["ollama-cloud"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
- ?: throw RepositoryError.ParseError("未找到 Ollama Cloud 模型列表")
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 Ollama Cloud 模型列表")
}
if (models.isEmpty()) {
- return@withContext Result.failure(RepositoryError.ParseError("模型列表为空"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt
index 087719a..fb56f8d 100644
--- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt
@@ -86,13 +86,14 @@ class OpenCodeGoRepository(
}
val html = resp.body?.string() ?: return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val windows = parseWindows(html)
if (windows.isEmpty()) {
return@withContext Result.failure(
RepositoryError.ParseError(
+ RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。"
)
)
@@ -151,15 +152,15 @@ class OpenCodeGoRepository(
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
- val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject
- ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
+ val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象")
val provider = root["opencode-go"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
- ?: throw RepositoryError.ParseError("未找到 OpenCode Go 模型列表")
+ ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenCode Go 模型列表")
}
if (models.isEmpty()) {
- return@withContext Result.failure(RepositoryError.ParseError("模型列表为空"))
+ return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt
index 26c8f30..55f0a6f 100644
--- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt
@@ -88,7 +88,7 @@ class OpenCodeUsageRepository(
}
val body = resp.body?.string() ?: return@withContext Result.failure(
- RepositoryError.ParseError("响应体为空")
+ RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
)
val records = parseUsageRecords(body)
diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt
index cfa38dd..08395d4 100644
--- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt
@@ -23,8 +23,24 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
class ServerError(val code: Int, cause: Throwable? = null) :
RepositoryError("服务端异常 (HTTP $code)", cause)
- /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因 */
- class ParseError(val detail: String, cause: Throwable? = null) :
+ /** 解析失败的具体原因(结构化错误码,UI 层据此映射本地化文案,detail 仅作日志/调试) */
+ enum class ParseErrorReason {
+ /** 响应体为空 */
+ EMPTY_BODY,
+ /** 响应根节点不是 JSON 对象 */
+ NOT_JSON_OBJECT,
+ /** 未找到用量窗口数据 */
+ NO_WINDOWS,
+ /** 未找到模型列表 */
+ NO_MODELS,
+ /** 模型列表为空 */
+ MODELS_EMPTY,
+ /** 响应格式异常(detail 含异常信息,仅供日志) */
+ MALFORMED_RESPONSE
+ }
+
+ /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因(日志用,不直接透传 UI) */
+ class ParseError(val reason: ParseErrorReason, val detail: String, cause: Throwable? = null) :
RepositoryError("解析失败: $detail", cause)
/** 未知错误 */
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
index 9e8fb69..4fe502b 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
@@ -189,7 +189,7 @@ data class DashboardCardUi(
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
}
-/** 把 Repository 错误映射为本地化文案(错误详情 detail 为技术信息,随资源参数展示)。 */
+/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
private fun errorToUiText(error: Throwable): UiText = when (error) {
is RepositoryError.InvalidCredential ->
UiText.Resource(R.string.error_credential_invalid_reconfigure)
@@ -204,8 +204,14 @@ private fun errorToUiText(error: Throwable): UiText = when (error) {
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))
+ is RepositoryError.ParseError -> when (error.reason) {
+ RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
+ RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
+ RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
+ RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
+ RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
+ RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
+ }
else -> error.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}
diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
index b82fc98..79f0da0 100644
--- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
@@ -536,8 +536,14 @@ class ServiceDetailViewModel @Inject constructor(
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))
+ is RepositoryError.ParseError -> when (error.reason) {
+ RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
+ RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
+ RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
+ RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
+ RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
+ RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
+ }
else -> error.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}
diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
index 18529d0..e0811ef 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
@@ -177,8 +177,14 @@ class CredentialEditViewModel @Inject constructor(
?: UiText.Resource(R.string.common_unknown)
)
)
- is RepositoryError.ParseError ->
- UiText.Resource(R.string.error_parse_failed, listOf(err.detail))
+ is RepositoryError.ParseError -> when (err.reason) {
+ RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body)
+ RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json)
+ RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows)
+ RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models)
+ RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty)
+ RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed)
+ }
else -> err?.message?.let { UiText.Dynamic(it) }
?: UiText.Resource(R.string.common_unknown)
}
diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties
index 3c5afff..6005758 100644
--- a/app/src/main/res/resources.properties
+++ b/app/src/main/res/resources.properties
@@ -1,5 +1,5 @@
# 默认(无 qualifier)资源对应的语言:英文(国际回退语言)
-# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-zh
-# AGP 据此 + values-zh 目录生成 locale config,Android 13+ 系统设置中
-# 会显示 RainyToken 的独立语言选项(English / 简体中文)
+# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-b+zh+Hans / values-b+zh+Hant
+# AGP 据此 + 中文资源目录生成 locale config,Android 13+ 系统设置中
+# 会显示 RainyToken 的独立语言选项(English / 简体中文 / 繁體中文)
unqualifiedResLocale=en
diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml
index 8bc27aa..52383f4 100644
--- a/app/src/main/res/values-b+zh+Hans/strings.xml
+++ b/app/src/main/res/values-b+zh+Hans/strings.xml
@@ -427,7 +427,12 @@
保存 Cookie
- 数据解析失败: %1$s
+ 响应体为空
+ 响应根节点不是 JSON 对象
+ 未找到用量窗口数据
+ 未找到模型列表
+ 模型列表为空
+ 响应格式异常
每周
模型调用次数
缓存数据 · %1$s
diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml
index 9ed8670..12821b7 100644
--- a/app/src/main/res/values-b+zh+Hant/strings.xml
+++ b/app/src/main/res/values-b+zh+Hant/strings.xml
@@ -427,7 +427,12 @@
儲存 Cookie
- 數據解析失敗: %1$s
+ 回應體為空
+ 回應根節點不是 JSON 物件
+ 未找到用量視窗資料
+ 未找到模型清單
+ 模型清單為空
+ 回應格式異常
每週
模型呼叫次數
快取數據 · %1$s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 67546ab..5d82a0a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -430,7 +430,12 @@
Save cookie
- Failed to parse data: %1$s
+ Empty response body
+ Response root is not a JSON object
+ No usage window data found
+ Model list not found
+ Model list is empty
+ Response format is abnormal
Weekly
Model call count
Cached data · %1$s