
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:
<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))Maven Central:
https://repo1.maven.org/maven2/io/mvdm/translationtools/translationtools-client-kmp/2.1.0/
Repository:
repositories {
mavenCentral()
}Runtime client:
dependencies {
implementation("io.mvdm.translationtools:translationtools-client-kmp:2.1.0")
}Optional Compose helpers:
dependencies {
implementation("io.mvdm.translationtools:translationtools-client-compose:2.1.0")
}Version catalog:
[libraries]
translationtools-client-kmp = { module = "io.mvdm.translationtools:translationtools-client-kmp", version = "2.1.0" }
translationtools-client-compose = { module = "io.mvdm.translationtools:translationtools-client-compose", version = "2.1.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.Translations.home_title.TranslationsBundledSnapshot, a bundled fallback snapshot from your local XML.TranslationToolsClient and calls initialize().Translations.*.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>apiKey: your-project-api-key
defaultLocale: en
locales:
- en
- nl
generated:
packageName: com.example.translations
androidResources:
resourceDirectories:
- src/androidMain/res
keyOverrides: {}
# Optional — only if you also sync Apple .strings files (see "Apple .strings (iOS)").
# appleResources:
# resourceDirectories:
# - ../iosApp/iosAppdefaultLocale and locales are shared across both platforms. The appleResources block
is optional; omitting it leaves the build Android-only and unchanged.
API key lookup order (Gradle plugin):
-Ptranslationtools.apiKey=...
TRANSLATIONTOOLS_API_KEY
apiKey in translationtools.yaml
The 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-keyPass 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 generateTranslationResourcesThis 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.TranslationToolsClientOptionsJVM 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 = "your-project-api-key",
currentLocaleProvider = {
context.resources.configuration.locales[0].toLanguageTag()
},
snapshotStore = AndroidTranslationSnapshotStores.fromContext(context),
bundledSnapshot = TranslationsBundledSnapshot.value,
),
)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 argumentcurrentLocaleProviderenIf 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:
./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../gradlew.bat pullTranslations
Downloads translations from TranslationTools, updates local XML (and Apple .strings), then regenerates Kotlin resources.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.pullTranslations when remote changes should be merged back into XML.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/iosAppEvery .strings file inside the discovered .lproj/ folders is then auto-discovered and
included in pushTranslations / pullTranslations. There is no per-key opt-out.
Behavior:
:/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.Sync-only, by design. Apple .strings get no generated Translations.* accessors and are
not part of TranslationsBundledSnapshot; the .lproj/*.strings files in the signed app
bundle are themselves the iOS bundled fallback. There is no runtime refresh for these keys, because
the OS (InfoPlist.strings) and native code (NSLocalizedString) 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 docs/adr/0001 and
docs/adr/0002.
.xcstrings (String Catalogs) are not supported.
If your app already uses Android strings.xml, migration is mostly mechanical.
src/androidMain/res/values*/strings.xml fileshome_title
Translations.*
TranslationToolsClient
strings.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.Typical 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 handRes.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.For a complete production setup, make sure all of these are true:
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.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:
<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))Maven Central:
https://repo1.maven.org/maven2/io/mvdm/translationtools/translationtools-client-kmp/2.1.0/
Repository:
repositories {
mavenCentral()
}Runtime client:
dependencies {
implementation("io.mvdm.translationtools:translationtools-client-kmp:2.1.0")
}Optional Compose helpers:
dependencies {
implementation("io.mvdm.translationtools:translationtools-client-compose:2.1.0")
}Version catalog:
[libraries]
translationtools-client-kmp = { module = "io.mvdm.translationtools:translationtools-client-kmp", version = "2.1.0" }
translationtools-client-compose = { module = "io.mvdm.translationtools:translationtools-client-compose", version = "2.1.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.Translations.home_title.TranslationsBundledSnapshot, a bundled fallback snapshot from your local XML.TranslationToolsClient and calls initialize().Translations.*.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>apiKey: your-project-api-key
defaultLocale: en
locales:
- en
- nl
generated:
packageName: com.example.translations
androidResources:
resourceDirectories:
- src/androidMain/res
keyOverrides: {}
# Optional — only if you also sync Apple .strings files (see "Apple .strings (iOS)").
# appleResources:
# resourceDirectories:
# - ../iosApp/iosAppdefaultLocale and locales are shared across both platforms. The appleResources block
is optional; omitting it leaves the build Android-only and unchanged.
API key lookup order (Gradle plugin):
-Ptranslationtools.apiKey=...
TRANSLATIONTOOLS_API_KEY
apiKey in translationtools.yaml
The 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-keyPass 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 generateTranslationResourcesThis 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.TranslationToolsClientOptionsJVM 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 = "your-project-api-key",
currentLocaleProvider = {
context.resources.configuration.locales[0].toLanguageTag()
},
snapshotStore = AndroidTranslationSnapshotStores.fromContext(context),
bundledSnapshot = TranslationsBundledSnapshot.value,
),
)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 argumentcurrentLocaleProviderenIf 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:
./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../gradlew.bat pullTranslations
Downloads translations from TranslationTools, updates local XML (and Apple .strings), then regenerates Kotlin resources.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.pullTranslations when remote changes should be merged back into XML.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/iosAppEvery .strings file inside the discovered .lproj/ folders is then auto-discovered and
included in pushTranslations / pullTranslations. There is no per-key opt-out.
Behavior:
:/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.Sync-only, by design. Apple .strings get no generated Translations.* accessors and are
not part of TranslationsBundledSnapshot; the .lproj/*.strings files in the signed app
bundle are themselves the iOS bundled fallback. There is no runtime refresh for these keys, because
the OS (InfoPlist.strings) and native code (NSLocalizedString) 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 docs/adr/0001 and
docs/adr/0002.
.xcstrings (String Catalogs) are not supported.
If your app already uses Android strings.xml, migration is mostly mechanical.
src/androidMain/res/values*/strings.xml fileshome_title
Translations.*
TranslationToolsClient
strings.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.Typical 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 handRes.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.For a complete production setup, make sure all of these are true:
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.