The apps show their text in the language of the system and fall back to English, unless one is
chosen in Settings → General → Language. Android 13+ and iOS keep that choice in their per-app
language setting (on iOS the row opens it in the Settings app); the desktop, the web app and
older Android keep it in config.toml as [appearance] language and apply it themselves
(i18n/AppLanguage.kt). Library, server and CLI output stay in English, as do log messages,
KetchError messages, REST error responses, the prompts AI discovery sends to the model and the
bug report "Copy details" puts on the clipboard.
| Language | Shared UI and desktop | Android | iOS | Browser extension |
|---|---|---|---|---|
| English (source) | values |
values |
en.lproj |
en |
| Chinese, Simplified | values-zh |
values-zh-rCN |
zh-Hans.lproj |
zh_CN |
| Chinese, Traditional | values-zh-rTW |
values-zh-rTW |
zh-Hant.lproj |
zh_TW |
| Korean | values-ko |
values-ko |
ko.lproj |
ko |
| Japanese | values-ja |
values-ja |
ja.lproj |
ja |
| Spanish | values-es |
values-es |
es.lproj |
es |
| Portuguese (Brazil) | values-pt |
values-pt |
pt-BR.lproj |
pt_BR |
| German | values-de |
values-de |
de.lproj |
de |
| French | values-fr |
values-fr |
fr.lproj |
fr |
| Russian | values-ru |
values-ru |
ru.lproj |
ru |
The glossary fixes how each language names the app's features and units; keep translations to it.
Chinese is written once per script: Simplified in values-zh, Traditional in values-zh-rTW.
The build copies Traditional for Hong Kong and Macau, which browsers and Windows report without a
script, and each script under its tag (zh-Hans, zh-Hant) for systems that report one, so
Simplified Chinese set up with a Hong Kong region still reads Simplified; see
app/shared/build.gradle.kts and LocaleFallbackTest.
Right-to-left languages are not supported yet: nothing has been checked in mirrored layouts.
The root README.md is the English source. Full translations live beside it in
README.zh-CN.md, README.es.md, README.ja.md, README.de.md and README.fr.md.
Each README links to all the others at the top, with language names written in their own language.
When changing the README, keep the translations in sync, including feature limits and roadmap items. Use the glossary and existing UI labels, preserve command examples and link destinations, and keep the explicit navigation anchors in translated files. Linked technical documentation remains in English.
- Shared UI (
app/shared):src/commonMain/composeResources/values/strings_<area>.xml, one file per area, such asstrings_tasks.xmlfor what download rows say orstrings_settings.xmlfor the Settings pages.strings_common.xmlholds units, durations, dates and the buttons every screen uses. - Desktop (
app/desktop): its menu bar, tray and dialogs, insrc/main/composeResources/values/strings.xml. - Android (
app/android): notifications and the service, insrc/main/res/values/strings.xml. - iOS (
app/ios): the permission prompt and document type names, in<language>.lproj/InfoPlist.strings. - Browser extension (
app/browser-extension):src/_locales/<language>/messages.json.
Never write user-facing text as a literal in app/shared/src/commonMain. HardcodedTextTest
counts the literals that read like words in each file and fails when a count goes up; the counts
in src/jvmTest/resources/hardcoded-text-allowlist.txt only hold text that stays English on
purpose, such as product names and the bug report. When a count goes down, run
./gradlew :app:shared:jvmTest -PupdateTokenAllowlist to lower it. To see the literals left in a
file, set its count to 0 in the allowlist and run the test: the failure lists them.
In a composable, read a string with stringResource(Res.string.key),
stringResource(Res.string.key, arg) or pluralStringResource(Res.plurals.key, count, count).
State and model code, which builds text outside composition, returns a UiText
(com.linroid.ketch.app.i18n), which keeps the resource and its arguments until the text is
shown:
val detail: UiText = listOfNotNull(
Res.plurals.row_connections.text(connections),
host?.let(::verbatim),
).joinText()Res.string.key.text(args)andRes.plurals.key.text(count)build it; an argument may be aUiTextitself. A plural's count is its%1$dunless other arguments are given.verbatim(text)marks text that is never translated: file names, hosts, paths, device names the user chose, messages from servers.joinText()joins parts with " · ", as rows and headers do.- Composables show it with
.resolve(); coroutines, such as notifications, read it with.load(), in the language of the app's window. - Never put a
UiTextin a string template orjoinToString: it prints its key, as⟦row_paused⟧.
Locale-aware formats live in i18n/Formats.kt: sizeText, speedText, etaText,
durationText, spanText, percentText, decimal, shortDateText, clockTime and
priorityText. Use them rather than formatting numbers, sizes or dates by hand.
Write one resource per sentence, with placeholders for what changes. Do not build sentences from fragments ("Couldn't " + verb + " on " + device) or pick words by count in code: word order, grammar and plural rules differ between languages. A phrase that starts in lower case because it follows other text, as the hints after a row's error do, gets a resource of its own.
<!-- %1$s is a device name, such as "NAS-Basement". -->
<string name="row_saved_on">Saved on %1$s</string>
<plurals name="row_files">
<item quantity="one">%1$d file</item>
<item quantity="other">%1$d files</item>
</plurals>- Keys are snake_case and start with their area:
row_,intake_,settings_,device_. Reuse a key only when the text means the same thing in the same kind of place. - Placeholders are positional,
%1$sor%1$d; Compose resources replace nothing else. Write a literal percent sign as%, not%%. - Compose resources keep text as written: write apostrophes and quotes plainly (
Couldn't, notCouldn\'t), escape only<and&(<,&), and keep each string on one line, which may run past 100 columns. Android's ownres/valuesfiles follow Android's rules instead, where apostrophes need a backslash. - Comment a string whose placeholders or place are not obvious, so translators know what fills it and where it shows.
- English plurals have
oneandother; translations use the categories their language has (fewandmanyin Russian, onlyotherin Chinese, Japanese and Korean).
JVM tests read the English strings: app/shared/build.gradle.kts sets the test JVM's language
and region to en-US, whatever the machine's. The iOS simulator tests read the simulator's
language, so keep it English. Assert text inside runTest with load():
@Test
fun rowContent_paused_showsPercent() = runTest {
assertEquals("Paused · 40%", rowContent(request, paused, now, context).detail.load())
}LocalizationResourcesTest checks every translation against English: no keys English lacks, the
same placeholders, plurals with an other form and no Android-style escapes. A key a translation
lacks falls back to English.
- Add it to the English file of its area, with a comment when it needs one.
- Use it from code as above.
- Add the translation to the other languages' copies of the file if you can; otherwise leave them, and the string shows in English there until a translator adds it.
- Copy each
strings_*.xmlofapp/shared/src/commonMain/composeResources/valuesand the desktop'sstrings.xmlinto avalues-<language>folder next to it, and translate them. Folders take an ISO 639-1 code, a region after-r(values-pt-rBR) or a BCP 47 tag (values-b+sr+Latn). Setlanguage_taginstrings_common.xmlto the language's tag, such aspt-BRorzh-Hant: the web app marks its page with it. - Translate
app/android/src/main/res/values/strings.xmlintovalues-<language>. Android 13+ offers the languages of these folders in the app's language setting; the build generates the list. - Add
<language>.lproj/InfoPlist.stringstoapp/iosand register it in the Xcode project: in theInfoPlist.stringsvariant group and inknownRegions. iOS offers the app in the languages of its.lprojfolders, and the macOS bundle lists the languages of the desktop'svalues-*folders. - Add
app/browser-extension/src/_locales/<language>/messages.json. - Check the web app in the new language. Browsers give Compose no system fonts; for scripts the bundled Inter and JetBrains Mono lack, Compose downloads Noto font slices from fonts.gstatic.com as text needs them, choosing the Chinese, Japanese or Korean variant from the browser's language. Offline, such text shows as boxes.
- Add it to
AppLanguagesini18n/AppLanguage.kt, named in itself, so Settings lists it. - Add it to the table above and run
./gradlew :app:shared:jvmTest;LocaleFallbackTestchecks that every translation is listed. To see the screens in it, render the UI snapshots with-PsnapshotLocale=<tag>(testing).
Translations come in by pull request. The files use the Android string resource format, which translation platforms such as Weblate and Crowdin read, should the project use one later.