sync: make the add-account flow's own messages translatable

Fourteen English sentences were built in the ViewModel and rendered
verbatim as fatal, Working.message and both error fields, so they
shipped untranslated to every locale. Three more sites passed through
whatever text the server, the repository or a Throwable produced.
Seventeen in all, and three of the fourteen were added by fixes earlier
on this branch -- which is the argument for a type rather than a rule.

This is the defect Outcome.Cause was introduced to fix, and it only ever
covered the address step. AddAccountMessage does the same for the rest:
a plain Kotlin sealed type in the app, with the resource mapping beside
Cause's in the screen. The state fields carry it, so a literal is now a
compile error rather than something review has to catch. Nothing lints
for this -- HardcodedText reads XML layout attributes and this app has
none -- so the type is the only guard there is.

The passthroughs get causes of their own, following the rule
Outcome.Cause already states: PollResult.Failed carries RATE_LIMITED,
MAINTENANCE or SERVER_ERROR, keeping the 429/503 distinction that
flattening to one message would lose, and CredentialFailed carries
KEYSTORE_REFUSED or NOT_SAVED. Their reason and detail stay for logs and
are never shown. A server's words are untranslatable and often a bare
status line; a Throwable's are worse.

Twenty keys, base locale only -- Weblate owns the rest and picks them up.
The four tests that asserted on English prose now assert on the message,
which for the cross-domain one is stricter: it pins the host into the
argument instead of anywhere in a sentence.
This commit is contained in:
2026-09-07 23:18:48 +02:00
parent b7e5031777
commit 6aaa15b130
7 changed files with 249 additions and 60 deletions
@@ -70,7 +70,27 @@ class NextcloudLoginFlow(
/** 404: still waiting. Also what an expired or already-consumed flow returns. */
data object Pending : PollResult
data class Expired(val reason: String) : PollResult
data class Failed(val reason: String) : PollResult
data class Failed(val cause: Cause, val reason: String) : PollResult
/**
* Why the flow ended, in a form the UI can translate.
*
* ⚠️ The UI must render *this*, never [Failed.reason]. A server's own
* words are untranslatable, often in a language the user does not read,
* and here they are frequently a bare status line or a JSON parser's
* complaint. [reason] exists for logs, and is never shown — the same rule
* [CalDavDiscovery.Outcome.Cause] states for discovery.
*/
enum class Cause {
/** Answering, but turning away repeated attempts (429). */
RATE_LIMITED,
/** Down on purpose (503). */
MAINTENANCE,
/** The server's own error, or an answer we could not read. */
SERVER_ERROR,
}
}
/**
@@ -148,22 +168,34 @@ class NextcloudLoginFlow(
val contentType = response.header("Content-Type").orEmpty()
// A Cloudflare challenge is a 200 carrying HTML.
if (!contentType.contains("application/json", ignoreCase = true)) {
PollResult.Failed("server answered 200 with $contentType, not JSON")
PollResult.Failed(
PollResult.Cause.SERVER_ERROR,
"server answered 200 with $contentType, not JSON",
)
} else {
parseCredentials(flow, response.body?.string().orEmpty())
}
}
response.code == 429 ->
PollResult.Failed("the server is rate-limiting this address (429)")
PollResult.Failed(
PollResult.Cause.RATE_LIMITED,
"the server is rate-limiting this address (429)",
)
response.code == 503 ->
PollResult.Failed("the server is in maintenance mode (503)")
PollResult.Failed(
PollResult.Cause.MAINTENANCE,
"the server is in maintenance mode (503)",
)
else -> PollResult.Failed("unexpected HTTP ${response.code}")
else -> PollResult.Failed(
PollResult.Cause.SERVER_ERROR,
"unexpected HTTP ${response.code}",
)
}
}
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
}.getOrElse { PollResult.Failed(PollResult.Cause.SERVER_ERROR, it.message ?: it.toString()) }
}
private fun parseCredentials(flow: Flow, body: String): PollResult = runCatching {
@@ -188,7 +220,7 @@ class NextcloudLoginFlow(
appPassword = root["appPassword"]?.jsonPrimitive?.content ?: error("no appPassword"),
),
)
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
}.getOrElse { PollResult.Failed(PollResult.Cause.SERVER_ERROR, it.message ?: it.toString()) }
/**
* These URLs are generated from `overwrite.cli.url` / `overwriteprotocol` /