Skip to content

Integrations

iOS & Android Integration

langctl pulls platform-specific files for iOS (Localizable.strings) and Android (strings.xml) with automatic parameter conversion.

iOS Integration

Pull for iOS

terminal
# once per repo: writes langctl.json
langctl init --project my-app --format ios --output "Resources/{lang}.lproj/Localizable.strings"

# then, whenever translations change
langctl pull

File Structure

MyApp/
  Resources/
    en.lproj/
      Localizable.strings
    es.lproj/
      Localizable.strings
    fr.lproj/
      Localizable.strings

Using Translations in Swift

swift
// Basic usage
let welcome = NSLocalizedString("home.welcome", comment: "Welcome message")

// With parameters
let greeting = String(
format: NSLocalizedString("home.greeting", comment: ""),
username
)

// SwiftUI
import SwiftUI

struct ContentView: View {
var body: some View {
  VStack {
    Text(LocalizedStringKey("home.welcome"))
    Text(LocalizedStringKey("home.subtitle"))
  }
}
}

langctl converts {{param}} to positional iOS format specifiers:

langctliOS Output
{{username}}%1$@
{{username}}, {{count}}%1$@, %2$@
{{name}} … {{name}}%1$@ … %1$@

A placeholder used twice keeps the same position, and a literal % is escaped where needed.

Android Integration

Pull for Android

terminal
# once per repo: writes langctl.json
langctl init --project my-app --format android --output "app/src/main/res/{android}/strings.xml"

# then, whenever translations change
langctl pull

{android} expands to the resource folder for each language: values for the project’s default language, values-es, values-pt-rBR, and so on.

File Structure

app/
  src/
    main/
      res/
        values/
          strings.xml      (English - default)
        values-es/
          strings.xml      (Spanish)
        values-fr/
          strings.xml      (French)

Using Translations in Kotlin

kotlin
// Basic usage
val welcome = getString(R.string.home_welcome)

// Set in TextView
binding.textView.text = getString(R.string.home_welcome)

// With parameters
val greeting = getString(R.string.home_greeting, username)
binding.textView.text = getString(R.string.home_greeting, username, count)

// In Compose
@Composable
fun HomeScreen() {
Column {
  Text(stringResource(R.string.home_welcome))
  Text(stringResource(R.string.home_subtitle))
}
}

langctl converts {{param}} to Android format and turns key names into valid resource names (home.welcome → home_welcome):

langctlAndroid Output
{{username}}%1$s
{{username}}, {{count}}%1$s, %2$s
{{name}} … {{name}}%1$s … %1$s

Note

Android uses positional parameters (%1$s, %2$s) so translators can reorder them. Apostrophes are escaped as Android requires. If two keys map to the same resource name (e.g. home.title and home_title), the pull fails and names both keys instead of silently dropping one.

CI/CD

Commit the pulled files and check them on every pull request with langctl pull --check (exit code 7 when they’re out of date) — see CI/CD Integration. If you’d rather pull at build time:

yaml
name: iOS Build

on: [push]

jobs:
build:
  runs-on: macos-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with: { node-version: 22 }

    - name: Pull Translations
      env:
        LANGCTL_API_KEY: ${{ secrets.LANGCTL_API_KEY }}
      run: npx langctl@0 pull    # uses langctl.json

    - name: Build iOS App
      run: xcodebuild -scheme MyApp -configuration Release
yaml
name: Android Build

on: [push]

jobs:
build:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with: { node-version: 22 }

    - name: Set up JDK
      uses: actions/setup-java@v4
      with:
        distribution: 'temurin'
        java-version: '17'

    - name: Pull Translations
      env:
        LANGCTL_API_KEY: ${{ secrets.LANGCTL_API_KEY }}
      run: npx langctl@0 pull    # uses langctl.json

    - name: Build Android App
      run: ./gradlew assembleRelease
ruby
lane :fetch_translations do
# Fastlane runs lanes from ./fastlane; langctl.json is found in parent directories
sh "npx langctl@0 pull"
end

lane :release do
fetch_translations
build_app(scheme: "MyApp")
upload_to_app_store
end
ruby
lane :fetch_translations do
# Fastlane runs lanes from ./fastlane; langctl.json is found in parent directories
sh "npx langctl@0 pull"
end

lane :release do
fetch_translations
gradle(task: "assembleRelease")
upload_to_play_store
end

Best Practices

iOS Best Practices

  1. Commit Localizable.strings:

    • Commit the pulled .lproj folders with your code
    • Run langctl pull --check in CI so they never drift
    • Output is sorted with no timestamps, so diffs show only real changes
  2. Localized String Keys:

    • Use descriptive keys: home.welcome not string_001
    • Group by feature: auth.login.title
    • Add comments for context
  3. Test RTL Languages:

    • Pull Arabic/Hebrew (langctl pull -l ar,he)
    • Test layout in Xcode
    • Use Auto Layout constraints

Android Best Practices

  1. Resource Qualifiers:

    • Use correct directory names: values-es, values-ar
    • Follow Android conventions
    • Test with different configurations
  2. Pluralization:

    • langctl writes plain <string> entries; it doesn’t generate <plurals> resources
    • Keep quantity strings in a separate, hand-maintained resource file, or use one key per case
  3. String Formatting:

    • Avoid hardcoded strings
    • Use string resources everywhere
    • Format numbers/dates properly

Tip

Always test translations on actual devices in different languages to catch layout and formatting issues.

Troubleshooting

iOS Issues

Strings not appearing:

  • Clean build folder (Cmd+Shift+K)
  • Check .lproj folder names match language codes
  • Verify Localizable.strings is in Copy Bundle Resources

Parameters not working:

  • Check String.format() syntax
  • Verify the argument order matches the positions (%1$@, %2$@)
  • Use String(format:) correctly

Android Issues

Resources not found:

  • Check values-XX folder naming
  • Ensure strings.xml is valid XML
  • Clean and rebuild project

Parameters not formatting:

  • Placeholders are written as strings (%1$s); pass formatted numbers as strings
  • Check positional indicators (%1$s, %2$s)
  • Look up resources by the converted name (R.string.home_welcome for home.welcome)

Next Steps