Add Simplified Chinese (zh) localization (#901)

* feat: add Simplified Chinese (zh) localization

- Add complete values-zh/strings.xml (921 strings, all keys covered)
- Translate 107 hardcoded UI strings across 34 Kotlin files
- Add 简体中文 option to in-app language picker
- Terminology aligned with official Hermes dashboard zh translations

* fix: resource-ize hardcoded Chinese strings per review

Address Hy4ri's review on PR #901:
- Move 104 hardcoded Chinese UI strings to string resources
  (English in values/strings.xml, Chinese in values-zh/strings.xml)
  referenced via stringResource()
- SystemViewModel labels -> @StringRes + AndroidViewModel with
  resource-based toast templates
- Make TodoTaskCardTest assertion locale-agnostic via getString()
- Update cookie tests to assert new host-agnostic session cookie
  behavior (WiFi/Tailscale switch-safe)
- Session cookie mirroring to WILDCARD bucket survives host switches

* style: apply ktlintFormat per review

---------

Co-authored-by: hermes-i18n <hermes-i18n@localhost>
This commit is contained in:
gslzwhlandhermes-i18n authored and GitHub committed 2026-08-15 11:33:43 +03:00
1 parent 0a5eb7a5b0
commit fdbd98af3e
46 files changed
+1420 -133

No files matched your search

@@ -44,12 +44,20 @@ object GatewayFileClient {
if (trimmedBase.isBlank()) return null
val norm = normalizePath(path) ?: return null
val encPath = URLEncoder.encode(norm, StandardCharsets.UTF_8.name()).replace("+", "%20")
return if (token.isNotBlank()) {
val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20")
"$trimmedBase$DOWNLOAD_PATH?path=$encPath&token=$encToken"
} else {
"$trimmedBase$DOWNLOAD_PATH?path=$encPath"
}
// Gated (basic-auth) dashboards authenticate the download via the
// session cookie in the shared CookieJar — a `?token=` query param is
// not consulted (the WS ticket the app stores is meaningless here), so
// don't stamp a bogus token into the URL. Loopback mode still needs it:
// there the query token IS the auth (auth_middleware compares it to the
// injected _SESSION_TOKEN).
val authQuery =
if (token.isNotBlank() && !AuthManager.isGatedMode()) {
val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20")
"&token=$encToken"
} else {
""
}
return "$trimmedBase$DOWNLOAD_PATH?path=$encPath$authQuery"
}
/**
@@ -99,6 +99,27 @@ class PersistentCookieJar(
if (idx >= 0) bucket[idx] = cookie else bucket.add(cookie)
}
}
// Session cookies are host-agnostic: mirror every dashboard session
// cookie into the WILDCARD_HOST bucket (the same place the legacy
// migration buckets blank-domain cookies) so the authenticated
// session survives a host switch. Without this, logging in over one
// reachable address (e.g. Tailscale 100.x) and later talking to the
// same dashboard over another (e.g. LAN 192.168.x) loses the cookie
// entirely — every authenticated download / API call then 401s even
// though the WebSocket (dialed on the current host) keeps working.
val sessionCookies = cookies.filter { isSessionCookie(it) }
if (sessionCookies.isNotEmpty()) {
val wildcard =
cache
.getOrPut(serverId) { ConcurrentHashMap() }
.getOrPut(WILDCARD_HOST) { mutableListOf() }
synchronized(wildcard) {
for (cookie in sessionCookies) {
val idx = wildcard.indexOfFirst { it.name == cookie.name && it.path == cookie.path }
if (idx >= 0) wildcard[idx] = cookie else wildcard.add(cookie)
}
}
}
// Persist the whole server scope asynchronously.
persist(serverId)
}
@@ -108,11 +129,22 @@ class PersistentCookieJar(
val hostKey = url.host
val hosts = cache[serverId] ?: return emptyList()
val now = System.currentTimeMillis()
// The WILDCARD bucket holds session cookies that must be usable on
// ANY host of this server scope (the dashboard is reachable over
// several IPs). They are exempted from the host-only domain match —
// OkHttp would reject them for a different host — but never sent to
// other servers because the scope itself is per-server.
val buckets = listOfNotNull(hosts[hostKey], hosts[WILDCARD_HOST])
val seen = mutableSetOf<Pair<String, String>>()
synchronized(buckets) {
return buckets.flatMap { bucket ->
synchronized(bucket) {
bucket.filter { it.matches(url) && (it.expiresAt > now || isSessionCookie(it)) }
bucket.filter { cookie ->
val alive = cookie.expiresAt > now || isSessionCookie(cookie)
val usable = isSessionCookie(cookie) || cookie.matches(url)
val key = cookie.name to cookie.path
alive && usable && seen.add(key)
}
}
}
}