
Type-safe native string resources from a single strings.ini: generates typed accessors, native resource files, plural rules, placeholder normalization, locale-aware formatting and composable StringDesc for deferred resolution.
Type-safe, native string resources for Kotlin Multiplatform, generated from a single strings.ini.
Write every translation once, in one file. bitsstrings generates the native resource files for Android and iOS plus a typed Kotlin accessor for every key. You get compile-time safety in shared code, and each platform still uses its own localization system at runtime.
strings.ini formatstrings.ini. No more hunting for the translation you forgot in file number seven.strings.xml / plurals.xml and an iOS String Catalog (Localizable.xcstrings). No custom lookup tables and no runtime parsing.zero, one, two, few, many, other, mapped onto each platform's plural rules.%d, %s, %f or iOS-style %@. The generator rewrites them to %1$s on Android and %1$@ on iOS.StringDesc. Build, format and combine strings in shared code and resolve them later on the platform.kotlinx-datetime extension.| Platform | Supported |
|---|---|
| Android | ✅ minSdk 24 (date-extension: 26) |
| iOS | ✅ iosX64, iosArm64, iosSimulatorArm64
|
| Android-only (no KMP) | ✅ via a dedicated plugin |
┌──────────────────────────────────────┐
│ androidMain │
│ res/values-*/strings.xml, plurals │
┌─▶│ actual object BitsStrings (R.string)│
┌──────────────┐ │ └──────────────────────────────────────┘
│ strings.ini │────┤ ┌──────────────────────────────────────┐
└──────────────┘ │ │ commonMain │
Gradle plugin ├─▶│ expect object BitsStrings │
│ └──────────────────────────────────────┘
│ ┌──────────────────────────────────────┐
│ │ iosMain + iosResourcesDir │
└─▶│ actual object BitsStrings (keys) │
│ Localizable.xcstrings │
└──────────────────────────────────────┘
Generation runs automatically before compilation and on Gradle sync, so the IDE always sees up-to-date accessors. You can also run it by hand with ./gradlew generateBitsStrings.
[versions]
bitsstrings = "1.0.0"
[libraries]
bitsfabrik-bitsstrings-core = { group = "com.bitsfabrik.bitsstrings", name = "core", version.ref = "bitsstrings" }
# optional
bitsfabrik-bitsstrings-date = { group = "com.bitsfabrik.bitsstrings", name = "date-extension", version.ref = "bitsstrings" }
[plugins]
bitsfabrik-bitsstrings-generator = { id = "com.bitsfabrik.bitsstrings.generator", version.ref = "bitsstrings" }Root build.gradle.kts:
plugins {
alias(libs.plugins.bitsfabrik.bitsstrings.generator) apply false
}Shared module build.gradle.kts:
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.bitsfabrik.bitsstrings.generator)
}
kotlin {
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
export(libs.bitsfabrik.bitsstrings.core) // expose StringDesc to Swift
}
}
sourceSets {
commonMain.dependencies {
api(libs.bitsfabrik.bitsstrings.core)
}
}
}All properties have defaults. Set only the ones you want to change:
bitsstrings {
resourcesPackage = "com.example.myapp" // default: "${project.group}.${project.name}"
resourcesClassName = "Strings" // default: "BitsStrings"
stringsIniPath = "../strings.ini" // default: "../strings.ini", relative to the module
iosResourcesDir = "../iosApp" // default: "../iosApp"
defaultLang = "en" // default: "de"
legacyGeneration = false // default: false, see below
}| Property | Description |
|---|---|
resourcesPackage |
Package of the generated accessor object |
resourcesClassName |
Name of the generated accessor object |
stringsIniPath |
Location of strings.ini, relative to the module |
iosResourcesDir |
Directory the iOS resource files are written to |
defaultLang |
Fallback used when a translation is missing |
legacyGeneration |
true writes <lang>.lproj/Localizable.strings instead of a String Catalog. Plurals are not supported in this mode |
The plugin writes Localizable.xcstrings into iosResourcesDir. Add that file to your app target in Xcode once. Later regenerations update it in place.
With legacyGeneration = true, it writes one <lang>.lproj/Localizable.strings per language instead. Add the folder as a folder reference so new languages are picked up automatically.
[[General]]
[general_label_ok]
de = OK
en = OK
[general_new_messages_count]
<zero>
de = Keine neuen Nachrichten
en = No new messages
<one>
de = Eine neue Nachricht
en = One new message
<other>
de = %d neue Nachrichten
en = %d new messages
[[Login]]
[login_label_password_reset_successful]
de = Es wurde eine E-Mail an %@ gesendet.
en = An e-mail has been sent to %@.| Syntax | Meaning |
|---|---|
[[Section]] |
Groups keys. Only for readability, it has no effect on the output |
[key_name] |
Defines a string. Each line below it is <language> = <value>
|
<zero> … <other>
|
Plural blocks: zero, one, two, few, many, other
|
%d, %s, %f, %@
|
Placeholders, rewritten per platform |
Escaping is handled for you: XML special characters are escaped on Android (supported HTML tags such as <b>, <i>, <a href>, <br> and <p> are kept), and quotes are escaped on iOS. When a translation is missing, the value from defaultLang is used.
For the ini above, the plugin generates roughly this:
// commonMain
expect object BitsStrings {
val general_label_ok: StringResource
val general_new_messages_count: PluralsResource
val login_label_password_reset_successful: StringResource
}
// androidMain
actual object BitsStrings {
actual val general_label_ok: StringResource = StringResource(R.string.general_label_ok)
// ...
}
// iosMain
actual object BitsStrings {
actual val general_label_ok: StringResource = StringResource("general_label_ok")
// ...
}Shared code works with StringDesc, a description of a string that the platform resolves later:
import com.bitsfabrik.bitsstrings.core.desc.*
import com.bitsfabrik.bitsstrings.core.res.format
val ok: StringDesc = BitsStrings.general_label_ok.desc()
val messages: StringDesc = BitsStrings.general_new_messages_count.desc(count)
val emailSent: StringDesc = BitsStrings.login_label_password_reset_successful.format(email)
val pluralFmt: StringDesc = BitsStrings.general_new_messages_count.format(count, count)
val raw: StringDesc = "Hello".desc()
// Composition
val combined = ok + " – ".desc() + messages
val list = listOf(a, b, c).joinToStringDesc(", ")
// Numbers & currency (locale-aware)
val price = StringDesc.Currency(19.9)
val amount = StringDesc.Number(1234.5, CommonNumberFormatter(1, 0, 2, useGrouping = true))
// Server-provided translations with fallback
val title = StringDesc.LocalizedRaw(
strings = mapOf("de" to "Hallo".desc(), "en" to "Hello".desc()),
fallbackLanguage = "en",
)
// Fully custom formatting
val custom = StringDesc.Custom(user) { u, locale -> "${u.firstName} (${locale.language})" }Tip:
PluralsResource.format(number, args…)takes the plural quantity first and the format arguments after it. That's whycountappears twice above.
val text: String = stringDesc.toString(context)
// with an explicit locale
val german = stringDesc.toString(context, StringDesc.LocaleType.Custom(BitsLocale("de-AT")))
// or directly from a resource
BitsStrings.general_label_ok.getString(context)let text = stringDesc.localized()StringDesc.LocaleType.Custom(locale:) resolves the string against a specific .lproj bundle, independent of the device language.
commonMain.dependencies {
api(libs.bitsfabrik.bitsstrings.core)
implementation(libs.bitsfabrik.bitsstrings.date)
}Adds StringDesc builders for kotlinx-datetime types, backed by platform formatters (LocalizedDateFormatter, LocalizedTimeFormatter, LocalizedDateTimeFormatter, LocalizedInstantFormatter):
val date: StringDesc = StringDesc.Date(LocalDate(2026, 9, 24), pattern = "dd. MMMM yyyy")
val time: StringDesc = StringDesc.Time(LocalTime(14, 30), pattern = "HH:mm")
val dateTime: StringDesc = StringDesc.DateTime(localDateTime, pattern = "dd.MM.yyyy HH:mm")
val instant: StringDesc = StringDesc.Instant(instant, pattern = "dd.MM.yyyy HH:mm")Each builder also accepts a custom formatter instead of a pattern.
Note: Requires Android minSdk 26.
date-extensiondepends oncoreascompileOnly, so you have to addcoreyourself.
For plain Android apps or libraries without Kotlin Multiplatform, use the dedicated plugin:
[plugins]
bitsfabrik-bitsstrings-android-generator = { id = "com.bitsfabrik.bitsstrings.android.generator", version.ref = "bitsstrings" }plugins {
id("com.android.application") // or com.android.library
alias(libs.plugins.bitsfabrik.bitsstrings.android.generator)
}
dependencies {
implementation(libs.bitsfabrik.bitsstrings.core)
}
bitsfabrikAndroidStrings {
resourcesPackage = "com.example.myandroidapp"
resourcesClassName = "Strings" // default: "BitsStrings"
stringsIniPath = "../strings.ini"
defaultLang = "en" // default: "de"
}This generates a plain object (no expect/actual) backed by R.string / R.plurals, together with the Android resource files. Generation runs before preBuild and on Gradle sync, or manually with ./gradlew generateAndroidStrings.
| Module | Coordinates | Description |
|---|---|---|
core |
com.bitsfabrik.bitsstrings:core |
KMP runtime: StringResource, PluralsResource, StringDesc, BitsLocale
|
date-extension |
com.bitsfabrik.bitsstrings:date-extension |
Optional localized date/time formatting for kotlinx-datetime
|
bitsstrings-generator |
plugin com.bitsfabrik.bitsstrings.generator
|
Gradle plugin for KMP projects |
bitsstrings-generator |
plugin com.bitsfabrik.bitsstrings.android.generator
|
Gradle plugin for pure Android projects |
The public API of bitsstrings is modelled on moko-resources by IceRock Development. If you've used moko, you already know the concepts: StringResource, PluralsResource, and the StringDesc family (Raw, Resource, Plural, Composition, …). On top of that API, bitsstrings is a framework of its own, tailored to a different workflow:
| moko-resources | bitsstrings | |
|---|---|---|
| Source of truth |
strings.xml per language |
One strings.ini holding all languages side by side |
| Scope | Strings, images, fonts, colors, files, … | Strings and plurals only: small and focused |
| iOS output |
.strings / .stringsdict
|
String Catalog (.xcstrings), or legacy .strings
|
| Placeholders | Android style | Android (%d, %s) and iOS style (%@), normalized per platform |
| Formatting | StringDesc |
StringDesc + number/currency, custom formatters, optional date/time |
| Android-only projects | — | Dedicated Android-only plugin |
If you need images, fonts, colors or other resource types, moko-resources is the more complete choice.
git clone https://github.com/bitsfabrik-opensource/bitsstrings.git
cd bitsstrings
./gradlew buildThe repository contains the core and date-extension libraries and the bitsstrings-generator Gradle plugin. Issues and pull requests are welcome.
StringResource, PluralsResource and StringDesc, and parts of the initial implementation (see NOTICE).Copyright 2026 bitsfabrik
Licensed under the Apache License, Version 2.0
See LICENSE for the full text.
Type-safe, native string resources for Kotlin Multiplatform, generated from a single strings.ini.
Write every translation once, in one file. bitsstrings generates the native resource files for Android and iOS plus a typed Kotlin accessor for every key. You get compile-time safety in shared code, and each platform still uses its own localization system at runtime.
strings.ini formatstrings.ini. No more hunting for the translation you forgot in file number seven.strings.xml / plurals.xml and an iOS String Catalog (Localizable.xcstrings). No custom lookup tables and no runtime parsing.zero, one, two, few, many, other, mapped onto each platform's plural rules.%d, %s, %f or iOS-style %@. The generator rewrites them to %1$s on Android and %1$@ on iOS.StringDesc. Build, format and combine strings in shared code and resolve them later on the platform.kotlinx-datetime extension.| Platform | Supported |
|---|---|
| Android | ✅ minSdk 24 (date-extension: 26) |
| iOS | ✅ iosX64, iosArm64, iosSimulatorArm64
|
| Android-only (no KMP) | ✅ via a dedicated plugin |
┌──────────────────────────────────────┐
│ androidMain │
│ res/values-*/strings.xml, plurals │
┌─▶│ actual object BitsStrings (R.string)│
┌──────────────┐ │ └──────────────────────────────────────┘
│ strings.ini │────┤ ┌──────────────────────────────────────┐
└──────────────┘ │ │ commonMain │
Gradle plugin ├─▶│ expect object BitsStrings │
│ └──────────────────────────────────────┘
│ ┌──────────────────────────────────────┐
│ │ iosMain + iosResourcesDir │
└─▶│ actual object BitsStrings (keys) │
│ Localizable.xcstrings │
└──────────────────────────────────────┘
Generation runs automatically before compilation and on Gradle sync, so the IDE always sees up-to-date accessors. You can also run it by hand with ./gradlew generateBitsStrings.
[versions]
bitsstrings = "1.0.0"
[libraries]
bitsfabrik-bitsstrings-core = { group = "com.bitsfabrik.bitsstrings", name = "core", version.ref = "bitsstrings" }
# optional
bitsfabrik-bitsstrings-date = { group = "com.bitsfabrik.bitsstrings", name = "date-extension", version.ref = "bitsstrings" }
[plugins]
bitsfabrik-bitsstrings-generator = { id = "com.bitsfabrik.bitsstrings.generator", version.ref = "bitsstrings" }Root build.gradle.kts:
plugins {
alias(libs.plugins.bitsfabrik.bitsstrings.generator) apply false
}Shared module build.gradle.kts:
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.bitsfabrik.bitsstrings.generator)
}
kotlin {
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
export(libs.bitsfabrik.bitsstrings.core) // expose StringDesc to Swift
}
}
sourceSets {
commonMain.dependencies {
api(libs.bitsfabrik.bitsstrings.core)
}
}
}All properties have defaults. Set only the ones you want to change:
bitsstrings {
resourcesPackage = "com.example.myapp" // default: "${project.group}.${project.name}"
resourcesClassName = "Strings" // default: "BitsStrings"
stringsIniPath = "../strings.ini" // default: "../strings.ini", relative to the module
iosResourcesDir = "../iosApp" // default: "../iosApp"
defaultLang = "en" // default: "de"
legacyGeneration = false // default: false, see below
}| Property | Description |
|---|---|
resourcesPackage |
Package of the generated accessor object |
resourcesClassName |
Name of the generated accessor object |
stringsIniPath |
Location of strings.ini, relative to the module |
iosResourcesDir |
Directory the iOS resource files are written to |
defaultLang |
Fallback used when a translation is missing |
legacyGeneration |
true writes <lang>.lproj/Localizable.strings instead of a String Catalog. Plurals are not supported in this mode |
The plugin writes Localizable.xcstrings into iosResourcesDir. Add that file to your app target in Xcode once. Later regenerations update it in place.
With legacyGeneration = true, it writes one <lang>.lproj/Localizable.strings per language instead. Add the folder as a folder reference so new languages are picked up automatically.
[[General]]
[general_label_ok]
de = OK
en = OK
[general_new_messages_count]
<zero>
de = Keine neuen Nachrichten
en = No new messages
<one>
de = Eine neue Nachricht
en = One new message
<other>
de = %d neue Nachrichten
en = %d new messages
[[Login]]
[login_label_password_reset_successful]
de = Es wurde eine E-Mail an %@ gesendet.
en = An e-mail has been sent to %@.| Syntax | Meaning |
|---|---|
[[Section]] |
Groups keys. Only for readability, it has no effect on the output |
[key_name] |
Defines a string. Each line below it is <language> = <value>
|
<zero> … <other>
|
Plural blocks: zero, one, two, few, many, other
|
%d, %s, %f, %@
|
Placeholders, rewritten per platform |
Escaping is handled for you: XML special characters are escaped on Android (supported HTML tags such as <b>, <i>, <a href>, <br> and <p> are kept), and quotes are escaped on iOS. When a translation is missing, the value from defaultLang is used.
For the ini above, the plugin generates roughly this:
// commonMain
expect object BitsStrings {
val general_label_ok: StringResource
val general_new_messages_count: PluralsResource
val login_label_password_reset_successful: StringResource
}
// androidMain
actual object BitsStrings {
actual val general_label_ok: StringResource = StringResource(R.string.general_label_ok)
// ...
}
// iosMain
actual object BitsStrings {
actual val general_label_ok: StringResource = StringResource("general_label_ok")
// ...
}Shared code works with StringDesc, a description of a string that the platform resolves later:
import com.bitsfabrik.bitsstrings.core.desc.*
import com.bitsfabrik.bitsstrings.core.res.format
val ok: StringDesc = BitsStrings.general_label_ok.desc()
val messages: StringDesc = BitsStrings.general_new_messages_count.desc(count)
val emailSent: StringDesc = BitsStrings.login_label_password_reset_successful.format(email)
val pluralFmt: StringDesc = BitsStrings.general_new_messages_count.format(count, count)
val raw: StringDesc = "Hello".desc()
// Composition
val combined = ok + " – ".desc() + messages
val list = listOf(a, b, c).joinToStringDesc(", ")
// Numbers & currency (locale-aware)
val price = StringDesc.Currency(19.9)
val amount = StringDesc.Number(1234.5, CommonNumberFormatter(1, 0, 2, useGrouping = true))
// Server-provided translations with fallback
val title = StringDesc.LocalizedRaw(
strings = mapOf("de" to "Hallo".desc(), "en" to "Hello".desc()),
fallbackLanguage = "en",
)
// Fully custom formatting
val custom = StringDesc.Custom(user) { u, locale -> "${u.firstName} (${locale.language})" }Tip:
PluralsResource.format(number, args…)takes the plural quantity first and the format arguments after it. That's whycountappears twice above.
val text: String = stringDesc.toString(context)
// with an explicit locale
val german = stringDesc.toString(context, StringDesc.LocaleType.Custom(BitsLocale("de-AT")))
// or directly from a resource
BitsStrings.general_label_ok.getString(context)let text = stringDesc.localized()StringDesc.LocaleType.Custom(locale:) resolves the string against a specific .lproj bundle, independent of the device language.
commonMain.dependencies {
api(libs.bitsfabrik.bitsstrings.core)
implementation(libs.bitsfabrik.bitsstrings.date)
}Adds StringDesc builders for kotlinx-datetime types, backed by platform formatters (LocalizedDateFormatter, LocalizedTimeFormatter, LocalizedDateTimeFormatter, LocalizedInstantFormatter):
val date: StringDesc = StringDesc.Date(LocalDate(2026, 9, 24), pattern = "dd. MMMM yyyy")
val time: StringDesc = StringDesc.Time(LocalTime(14, 30), pattern = "HH:mm")
val dateTime: StringDesc = StringDesc.DateTime(localDateTime, pattern = "dd.MM.yyyy HH:mm")
val instant: StringDesc = StringDesc.Instant(instant, pattern = "dd.MM.yyyy HH:mm")Each builder also accepts a custom formatter instead of a pattern.
Note: Requires Android minSdk 26.
date-extensiondepends oncoreascompileOnly, so you have to addcoreyourself.
For plain Android apps or libraries without Kotlin Multiplatform, use the dedicated plugin:
[plugins]
bitsfabrik-bitsstrings-android-generator = { id = "com.bitsfabrik.bitsstrings.android.generator", version.ref = "bitsstrings" }plugins {
id("com.android.application") // or com.android.library
alias(libs.plugins.bitsfabrik.bitsstrings.android.generator)
}
dependencies {
implementation(libs.bitsfabrik.bitsstrings.core)
}
bitsfabrikAndroidStrings {
resourcesPackage = "com.example.myandroidapp"
resourcesClassName = "Strings" // default: "BitsStrings"
stringsIniPath = "../strings.ini"
defaultLang = "en" // default: "de"
}This generates a plain object (no expect/actual) backed by R.string / R.plurals, together with the Android resource files. Generation runs before preBuild and on Gradle sync, or manually with ./gradlew generateAndroidStrings.
| Module | Coordinates | Description |
|---|---|---|
core |
com.bitsfabrik.bitsstrings:core |
KMP runtime: StringResource, PluralsResource, StringDesc, BitsLocale
|
date-extension |
com.bitsfabrik.bitsstrings:date-extension |
Optional localized date/time formatting for kotlinx-datetime
|
bitsstrings-generator |
plugin com.bitsfabrik.bitsstrings.generator
|
Gradle plugin for KMP projects |
bitsstrings-generator |
plugin com.bitsfabrik.bitsstrings.android.generator
|
Gradle plugin for pure Android projects |
The public API of bitsstrings is modelled on moko-resources by IceRock Development. If you've used moko, you already know the concepts: StringResource, PluralsResource, and the StringDesc family (Raw, Resource, Plural, Composition, …). On top of that API, bitsstrings is a framework of its own, tailored to a different workflow:
| moko-resources | bitsstrings | |
|---|---|---|
| Source of truth |
strings.xml per language |
One strings.ini holding all languages side by side |
| Scope | Strings, images, fonts, colors, files, … | Strings and plurals only: small and focused |
| iOS output |
.strings / .stringsdict
|
String Catalog (.xcstrings), or legacy .strings
|
| Placeholders | Android style | Android (%d, %s) and iOS style (%@), normalized per platform |
| Formatting | StringDesc |
StringDesc + number/currency, custom formatters, optional date/time |
| Android-only projects | — | Dedicated Android-only plugin |
If you need images, fonts, colors or other resource types, moko-resources is the more complete choice.
git clone https://github.com/bitsfabrik-opensource/bitsstrings.git
cd bitsstrings
./gradlew buildThe repository contains the core and date-extension libraries and the bitsstrings-generator Gradle plugin. Issues and pull requests are welcome.
StringResource, PluralsResource and StringDesc, and parts of the initial implementation (see NOTICE).Copyright 2026 bitsfabrik
Licensed under the Apache License, Version 2.0
See LICENSE for the full text.