translation-tools-client-kmp
2.3.0indexedBootstraps translations from remote or bundled snapshot, persists local snapshots, serves cache-first reads with single-item fetch on miss, supports typed resources, Compose integration, background refresh.
Bootstraps translations from remote or bundled snapshot, persists local snapshots, serves cache-first reads with single-item fetch on miss, supports typed resources, Compose integration, background refresh.
translationtools-client-kmp is a Kotlin Multiplatform client for TranslationTools.
It keeps Android strings.xml as the source of truth, generates typed Translations.* accessors for shared code, bundles fallback translations into your app, and refreshes translations from TranslationTools at runtime.
Use it when you want all of this at once:
Current scope:
Maven Central:
https://repo1.maven.org/maven2/io/mvdm/translationtools/translationtools-client-kmp/2.3.0/
Repository:
repositories {
mavenCentral()
}
Runtime client:
dependencies {
implementation("io.mvdm.translationtools:translationtools-client-kmp:2.3.0")
}
Optional Compose helpers:
dependencies {
implementation("io.mvdm.translationtools:translationtools-client-compose:2.3.0")
}
Version catalog:
[libraries]
translationtools-client-kmp = { module = "io.mvdm.translationtools:translationtools-client-kmp", version = "2.3.0" }
translationtools-client-compose = { module = "io.mvdm.translationtools:translationtools-client-compose", version = "2.3.0" }
dependencies {
implementation(libs.translationtools.client.kmp)
}
To generate Translations.*, your module also needs the io.mvdm.translationtools.plugin Gradle plugin. That plugin reads Android XML and generates the typed resource API used by this client.
The plugin is not yet published to a repository. Include it as a composite build from your
consumer project. Copy or clone the gradle/translationtools-plugin directory, then add it
to your settings.gradle.kts:
pluginManagement {
includeBuild("path/to/translationtools-plugin")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
Then apply it in your module:
plugins {
id("io.mvdm.translationtools.plugin")
}
Compatibility: the plugin is compiled with Kotlin 2.1.20 and works with Gradle 8.x and 9.x. Consumer projects can use any Kotlin version from 1.9.25 through current 2.x releases.
src/androidMain/res/values*/**/*.xml.plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.library")
id("io.mvdm.translationtools.plugin")
}
The runtime dependency alone does not generate resources.
Example src/androidMain/res/values/strings.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="home_title">Home</string>
<string name="checkout_title">Checkout</string>
</resources>
Example src/androidMain/res/values-nl/strings.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="home_title">Start</string>
<string name="checkout_title">Afrekenen</string>
</resources>
translationtools.yaml at the project rootapiKey: your-project-api-key
defaultLocale: en
locales:
- en
- nl
generated:
{}
defaultLocale and locales are shared across both platforms. The appleResources block
is optional; omitting it leaves the build Android-only and unchanged.
Config fields:
API key lookup order (Gradle plugin):
-Ptranslationtools.apiKey=...TRANSLATIONTOOLS_API_KEYapiKey in translationtools.yamlThe runtime client also needs the API key to refresh translations at app startup. On
Android, a common approach is to expose it via BuildConfig:
// androidApp/build.gradle.kts
buildTypes {
debug {
buildConfigField("String", "TRANSLATION_TOOLS_API_KEY",
"\"${localProperties.getProperty("TRANSLATIONTOOLS_API_KEY") ?: System.getenv("TRANSLATIONTOOLS_API_KEY") ?: ""}\"")
}
}
Then set the key in local.properties (not committed to version control):
TRANSLATIONTOOLS_API_KEY=your-project-api-key
Pass it when creating the client:
TranslationToolsClientOptions(
apiKey = BuildConfig.TRANSLATION_TOOLS_API_KEY,
backgroundRefreshEnabled = BuildConfig.TRANSLATION_TOOLS_API_KEY.isNotBlank(),
// ...
)
Without a valid API key, backgroundRefreshEnabled should be false and the app will
only use bundled fallback translations.
./gradlew.bat generateTranslationResources
This generates:
Translations.* for typed string accessTranslationsBundledSnapshot for bundled fallback dataThe plugin also wires generation into Kotlin compilation, so normal builds regenerate resources automatically.
Common shape:
import io.ktor.client.HttpClient
import io.mvdm.translationtools.client.TranslationTools
import io.mvdm.translationtools.client.TranslationToolsClientOptions
JVM example:
import com.example.translations.TranslationsBundledSnapshot
import io.ktor.client.HttpClient
import io.mvdm.translationtools.client.JvmTranslationSnapshotStores
import io.mvdm.translationtools.client.TranslationTools
import io.mvdm.translationtools.client.TranslationToolsClientOptions
val client = TranslationTools.createClient(
httpClient = HttpClient(),
options = TranslationToolsClientOptions(
apiKey = "your-project-api-key",
currentLocaleProvider = { "en" },
snapshotStore = JvmTranslationSnapshotStores.default(),
bundledSnapshot = TranslationsBundledSnapshot.value,
),
)
Android example:
import android.content.Context
import com.example.translations.TranslationsBundledSnapshot
import io.ktor.client.HttpClient
import io.mvdm.translationtools.client.AndroidTranslationSnapshotStores
import io.mvdm.translationtools.client.TranslationTools
import io.mvdm.translationtools.client.TranslationToolsClientOptions
fun createTranslationsClient(context: Context) = TranslationTools.createClient(
httpClient = HttpClient(),
options = TranslationToolsClientOptions(
apiKey = ,
currentLocaleProvider = {
context.resources.configuration.locales[].toLanguageTag()
},
snapshotStore = AndroidTranslationSnapshotStores.fromContext(context),
bundledSnapshot = TranslationsBundledSnapshot.value,
),
)
iOS example. There is no iOS-specific snapshot-store helper; use the common
TranslationSnapshotStores.file(...) with a path inside the app sandbox:
import com.example.translations.TranslationsBundledSnapshot
io.ktor.client.HttpClient
io.mvdm.translationtools.client.TranslationSnapshotStores
io.mvdm.translationtools.client.TranslationTools
io.mvdm.translationtools.client.TranslationToolsClientOptions
okio.FileSystem
platform.Foundation.NSDocumentDirectory
platform.Foundation.NSLocale
platform.Foundation.NSSearchPathForDirectoriesInDomains
platform.Foundation.NSUserDomainMask
documentsPath = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory, NSUserDomainMask, ,
).first() String
client = TranslationTools.createClient(
httpClient = HttpClient(),
options = TranslationToolsClientOptions(
apiKey = ,
currentLocaleProvider = { NSLocale.currentLocale.languageCode },
snapshotStore = TranslationSnapshotStores.file(
filePath = ,
fileSystem = FileSystem.SYSTEM,
),
bundledSnapshot = TranslationsBundledSnapshot.value,
),
)
TranslationSnapshotStores.file(filePath, fileSystem) works on every target;
JvmTranslationSnapshotStores and AndroidTranslationSnapshotStores are just platform
conveniences over it. If you omit snapshotStore, the client uses an in-memory no-op store and
nothing is persisted across launches.
suspend fun startTranslations() {
client.initialize()
}
initialize() does this:
import com.example.translations.Translations
val cachedTitle = client.getCached(Translations.home_title)
val title = client.get(Translations.home_title)
val titleUpdates = client.observe(Translations.home_title)
Behavior:
getCached(...) returns cached value, otherwise XML fallback, otherwise the keyget(...) returns cached value first and fetches from TranslationTools on cache missobserve(...) exposes a Flow that updates when translations changeLocale resolution order:
locale argumentcurrentLocaleProviderenTranslationToolsClientOptions controls runtime behavior. Only apiKey is required.
initialize() restores the cache and, when backgroundRefreshEnabled is set, refreshes once in the
background. You can also drive refresh manually:
client.refresh() // force an immediate refresh
client.refreshIfStale() // refresh only if older than refreshInterval
Observe refresh status to drive UI — an "updating…" indicator, or an offline banner on failure:
import io.mvdm.translationtools.client.TranslationRefreshStatus
client.observeRefreshState().collect { state ->
when (state.status) {
TranslationRefreshStatus.Failed -> showOffline(state.lastFailureMessage)
TranslationRefreshStatus.Ready -> hideOffline()
else -> { /* Idle, RestoringCache, Refreshing */ }
}
// state.lastSuccessfulRefreshAt is also available
}
Translation values may contain named placeholders as ICU-compatible tokens — { + a camelCase
identifier + }, e.g. Hello {userName}. A literal brace is written with the ICU apostrophe escape
('{'). On KMP you bind placeholders with the string-keyed API (typed accessors are not generated for
KMP yet); the value is substituted at read time.
// One-shot map:
val greeting = client.get(Translations.greeting, placeholders = mapOf("userName" to "Sam"))
// Or the fluent builder:
val greeting2 = client.withPlaceholders(Translations.greeting)
.setPlaceholder("userName", "Sam")
.render()
Global placeholders are available to every key. Register them once in options as ambient resolvers (evaluated per render); their names are pushed to TranslationTools at startup so they appear in the management UI:
val client = TranslationTools.createClient(
httpClient = HttpClient(),
options = TranslationToolsClientOptions(
apiKey = "...",
environment = "production",
globalPlaceholders = mapOf(
"appName" to { "My App" },
"userName" to { currentUser()?.name },
),
),
)
Failure behavior (a token with no binding and no registered global, or a global whose resolver throws
or returns null): the default is degrade — render the raw {token}. Set
throwOnPlaceholderError = true in options to throw a PlaceholderSubstitutionException instead. A
per-call binding shadows a global of the same name.
getCached(...) returns the raw value and does not substitute; use get(...) /
withPlaceholders(...) to render placeholders.
If you use Compose, add translationtools-client-compose and provide the client through composition locals.
Compose artifact targets:
androidjvmiosX64iosArm64iosSimulatorArm64import androidx.compose.runtime.CompositionLocalProvider
import com.example.translations.Translations
import io.mvdm.translationtools.client.compose.LocalTranslationToolsClient
import io.mvdm.translationtools.client.compose.LocalTranslationToolsLocale
import io.mvdm.translationtools.client.compose.stringResource
CompositionLocalProvider(
LocalTranslationToolsClient provides client,
LocalTranslationToolsLocale provides "en",
) {
val title = stringResource(Translations.home_title)
}
Available Gradle tasks:
generateTranslationResources stays Android-only; it never reads Apple .strings.
Normal workflow:
src/androidMain/res/values*/**/*.xml.generateTranslationResources.Translations.* in shared or platform code.pushTranslations when local XML should become the remote state..strings (iOS)iOS apps ship localized copy outside Android XML — Apple .strings files such as
InfoPlist.strings (app display name, permission usage descriptions) and
Localizable.strings, living in <locale>.lproj/ directories. You can manage these in
TranslationTools as a second source of truth alongside Android XML.
Point the plugin at the directories that contain your .lproj/ folders (paths may resolve
outside the Gradle module, e.g. an Xcode app target beside the shared module):
appleResources:
resourceDirectories:
- ../iosApp/iosApp
Every .strings file inside the discovered .lproj/ folders is then auto-discovered and
included in pushTranslations / pullTranslations. There is no per-key opt-out.
Behavior:
Apple get generated accessors and are part of ; the files in the signed app bundle are themselves the iOS bundled fallback. There is no runtime refresh for these keys, because the OS () and native code () read them from the read-only bundle — there is no delivery path for a runtime-fetched value. Making app-owned iOS UI text runtime-updatable (routing native reads through the KMP client) is a separate, larger initiative. See and .
.xcstrings (String Catalogs) are not supported.
strings.xmlIf your app already uses Android strings.xml, migration is mostly mechanical.
src/androidMain/res/values*/strings.xml fileshome_titleTranslations.*TranslationToolsClientTypical code migration:
// before
context.getString(R.string.home_title)
// after
client.get(Translations.home_title)
Compose migration:
// before
androidx.compose.ui.res.stringResource(R.string.home_title)
// after
io.mvdm.translationtools.client.compose.stringResource(Translations.home_title)
Shared code migration:
// before
"Home"
// after
client.get(Translations.home_title)
<string> entries are generated<plurals> and <string-array> are skipped and need separate handlingbuild/generated/...; do not edit them by handFor a complete production setup, make sure all of these are true:
<string> resourcesTranslations.* and TranslationsBundledSnapshot<plurals> and <string-array>.strings files (InfoPlist.strings, Localizable.strings) as a
second source of truth — sync-only: push/pull plus the bundled .lproj files, with
no generated Translations.* accessors and no runtime refresh (see Apple .strings (iOS))Translations.home_titleTranslationsBundledSnapshot, a bundled fallback snapshot from your local XML.TranslationToolsClient and calls initialize().Translations.*.apiKey — project API key. Can also come from a Gradle property or env var (see lookup order below).defaultLocale — base locale; defaults to en.locales — the project's locale set.generated.packageName — package for the generated Translations / TranslationsBundledSnapshot.
Defaults to <android-namespace>.translations (falling back to <project.group>.translations).androidResources.resourceDirectories — where your values*/**.xml live. Defaults to
src/androidMain/res.androidResources.keyOverrides — rename an XML name to a different translation key, written as
xml-name: translation-key. Applied consistently to generation and push/pull. Defaults to {}.androidResources.prune — when true, pushTranslations deletes remote keys that no longer exist
locally (the remote becomes an exact mirror of local); -Ptranslationtools.prune overrides it.
Defaults to false (merge — remote-only keys are kept).appleResources.resourceDirectories — optional; enables Apple .strings sync (see
Apple .strings (iOS)).| Option | Type | Default | Purpose |
|---|
apiKey | String | — (required) | Project API key used to refresh translations and send heartbeats. |
currentLocaleProvider | () -> String? | { null } | Resolves the active locale per read (see locale resolution order above). |
preferredLocales | Set<String> | emptySet() | Limits which locales are downloaded on refresh. When empty, the client fetches currentLocaleProvider() + the project default. Set it to pre-fetch a fixed set. |
snapshotStore | TranslationSnapshotStore | no-op (no persistence) | Where refreshed translations are cached between launches. |
bundledSnapshot | StoredTranslations? | null | Generated TranslationsBundledSnapshot.value, used as offline fallback before the first refresh. |
backgroundRefreshEnabled | Boolean | true | Whether initialize() starts a background refresh after restoring the cache. Set false when no API key is available. |
refreshInterval | Duration | 1.hours | Staleness window for refreshIfStale() and the background refresh. |
environment | String? | null | Scopes translations and global placeholders to a named server-side environment (e.g. "production", "staging"). |
heartbeatEnabled | Boolean | true | Periodically reports this client (platform, version, environment) so active clients appear in the TranslationTools management UI. |
heartbeatInterval | Duration | 1.hours | How often the heartbeat is sent. |
globalPlaceholders | Map<String, () -> String?> | emptyMap() | Ambient placeholder resolvers available to every key (see Placeholders). |
throwOnPlaceholderError | Boolean | false | Throw PlaceholderSubstitutionException on an unresolved placeholder instead of degrading to the raw token. |
./gradlew.bat initTranslationTools
Creates a starter translationtools.yaml../gradlew.bat generateTranslationResources
Generates Translations.* and TranslationsBundledSnapshot from local XML../gradlew.bat pushTranslations
Uploads local XML — and, when appleResources is configured, Apple .strings — to TranslationTools.
By default this merges: remote keys you don't have locally are preserved. To make the remote
exactly match local (deleting remote-only keys), set androidResources.prune: true in
translationtools.yaml, or run with -Ptranslationtools.prune=true (the Gradle property overrides
the config value).
Strings marked translatable="false" are local-only — never pushed../gradlew.bat pullTranslations
Downloads translations from TranslationTools, updates local XML (and Apple .strings), then regenerates Kotlin resources.
Existing entries are updated in place; new keys are added. Keys not managed remotely are written back
with translatable="false".pullTranslations when remote changes should be merged back into XML.:/InfoPlist.strings, :/Localizable.strings),
parallel to :/strings.xml, so identical keys across platforms never collide..lproj names map to the shared lowercase-hyphen locale axis: en.lproj → en,
pt-BR.lproj (and legacy pt_BR) → pt-br, Base.lproj → defaultLocale. If both Base.lproj
and an explicit default-locale directory exist, the explicit one wins and a warning is emitted.
Write-back uses Apple's conventional casing (pt-BR.lproj).\", \\, \n, \t, \Uxxxx). Unparseable lines are warned-and-skipped, never fatal..lproj/<file>.strings
is created (with a managed-by-TranslationTools header) plus a warning to add the region to your
Xcode project's knownRegions — the plugin does not edit project.pbxproj..stringsTranslations.*TranslationsBundledSnapshot.lproj/*.stringsInfoPlist.stringsNSLocalizedStringstrings.xml files under src/androidMain/res/values*/.io.mvdm.translationtools.plugin to the module that owns those files.translationtools.yaml../gradlew.bat generateTranslationResources.TranslationToolsClient.Translations.*../gradlew.bat pushTranslations once to upload your current XML to TranslationTools.Res.string.* to Translations.* (avoids clash with Compose's Res).ResBundledSnapshot to TranslationsBundledSnapshot.generated.objectName key in translationtools.yaml has been removed. The generated object is always named Translations. Remove objectName from your config.Res.string.foo with Translations.foo and update imports to <your.package>.Translations / <your.package>.TranslationsBundledSnapshot.translationtools-client-kmp is in your dependencies.io.mvdm.translationtools.plugin is applied to the module with Android XML resources.translationtools.yaml exists in the project root.src/androidMain/res/values/.TranslationToolsClient and calls initialize() at startup.Translations.*.pushTranslations and pullTranslations to sync local XML with TranslationTools.Surfaced from shared tags and platforms — no rankings paid for.