BeMyWords with Android
Integration guide for Android apps using the standard res/values/strings.xml format.
Shape of the integration:
- At build time, fetch translations from BeMyWords and convert to
strings.xmlunderres/values-<lang>/. - Use
R.string.<name>orgetString(R.string.<name>)as normal. - Optionally push your default
strings.xmlto BeMyWords as the English master.
Prerequisites
- An Android Studio project with at least
app/src/main/res/values/strings.xml. - A BeMyWords workspace, project, namespace, API key.
- Node.js available in your build environment (the example uses Node; any scripting language works).
1. Environment variables
Locally in .env (gitignored) at project root; in CI, via your CI's secret store:
BEMYWORDS_BASE_URL=https://www.bemywords.no
BEMYWORDS_PROJECT_ID=<your-project-uuid>
BEMYWORDS_API_TOKEN=<your-api-token>
BEMYWORDS_NAMESPACE=android
2. Key naming convention
Android strings.xml requires names that match [a-z][a-z0-9_]*. BeMyWords allows dots and dashes — you need a convention.
Recommendation: use dots in BeMyWords (home.hero.title) and convert to underscores for Android (home_hero_title). Set and enforce this convention across your team before you start.
3. Fetch + convert script
scripts/fetch-translations.mjs:
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const RES_DIR = join(__dirname, "..", "app", "src", "main", "res");
const BASE_URL = process.env.BEMYWORDS_BASE_URL || "https://www.bemywords.no";
const PROJECT_ID = process.env.BEMYWORDS_PROJECT_ID;
const API_TOKEN = process.env.BEMYWORDS_API_TOKEN;
const NAMESPACE = process.env.BEMYWORDS_NAMESPACE || "android";
// BeMyWords lang → Android values-<qualifier>
// Default (English) goes in values/, not values-en/
const LANGUAGES = {
en: "values",
nb: "values-nb",
"pt-BR": "values-pt-rBR",
};
function toAndroidName(key) {
// Dots + dashes → underscores. Normalize to [a-z][a-z0-9_]*
const name = key.replace(/[.\-]/g, "_").toLowerCase();
if (!/^[a-z][a-z0-9_]*$/.test(name)) {
console.warn(` skipping invalid name for Android: ${key}`);
return null;
}
return name;
}
function escapeXmlText(s) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, "\\\"")
.replace(/'/g, "\\'")
.replace(/\n/g, "\\n");
}
function toStringsXml(dict) {
const lines = ['<?xml version="1.0" encoding="utf-8"?>'];
lines.push('<!-- Generated by fetch-translations.mjs from BeMyWords. Do not edit. -->');
lines.push("<resources>");
for (const [key, value] of Object.entries(dict).sort()) {
const name = toAndroidName(key);
if (!name) continue;
lines.push(` <string name="${name}">${escapeXmlText(value)}</string>`);
}
lines.push("</resources>");
return lines.join("\n") + "\n";
}
async function fetchLanguage(bwLang) {
const url = `${BASE_URL}/api/${PROJECT_ID}/latest/${bwLang}/${NAMESPACE}`;
const res = await fetch(url, {
headers: { Authorization: `Token token=${API_TOKEN}` },
});
if (!res.ok) throw new Error(`${bwLang}: HTTP ${res.status}`);
return res.json();
}
async function main() {
if (!PROJECT_ID || !API_TOKEN) {
console.warn("BEMYWORDS_PROJECT_ID or BEMYWORDS_API_TOKEN not set.");
return;
}
for (const [bwLang, valuesDir] of Object.entries(LANGUAGES)) {
const data = await fetchLanguage(bwLang);
const outDir = join(RES_DIR, valuesDir);
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, "strings.xml");
writeFileSync(outPath, toStringsXml(data), "utf-8");
console.log(` [${bwLang}] ${Object.keys(data).length} keys → ${outPath}`);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
4. Hook into the Gradle build
Edit app/build.gradle (or build.gradle.kts):
tasks.register("fetchTranslations", Exec) {
workingDir = rootDir
commandLine "node", "scripts/fetch-translations.mjs"
}
preBuild.dependsOn("fetchTranslations")
Kotlin DSL:
tasks.register<Exec>("fetchTranslations") {
workingDir = rootDir
commandLine("node", "scripts/fetch-translations.mjs")
}
tasks.named("preBuild") { dependsOn("fetchTranslations") }
Alternatively, run the script manually or from CI before ./gradlew build.
5. Use in code
val title = getString(R.string.home_hero_title)
val greeting = getString(R.string.greeting, userName)
Android positional format specifiers (%1$s, %2$d) pass through BeMyWords verbatim. Enable Android-aware placeholder validation in your BeMyWords project settings.
6. (Optional) Push English to BeMyWords
Parse app/src/main/res/values/strings.xml and PUT to BeMyWords:
import { readFileSync } from "node:fs";
const path = join(RES_DIR, "values", "strings.xml");
const xml = readFileSync(path, "utf-8");
const re = /<string\s+name="([^"]+)">([\s\S]*?)<\/string>/g;
const translations = {};
let match;
while ((match = re.exec(xml)) !== null) {
const androidName = match[1];
const rawValue = match[2];
const value = rawValue
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\n/g, "\n")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/&/g, "&");
// Reverse the underscore convention if you want dots in BeMyWords:
// const key = androidName.replace(/_/g, "."); // only if your naming round-trips
const key = androidName;
translations[key] = value;
}
const url = `${BASE_URL}/api/overwrite/${PROJECT_ID}/latest/en/${NAMESPACE}`;
await fetch(url, { method: "PUT", headers: { Authorization: `Token token=${API_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ translations }) });
Note the irreversibility concern: if you're converting BeMyWords dots → Android underscores on fetch, you can't trivially reverse on push unless your naming is strictly foo_bar_baz with no collisions. Either:
- Keep underscore names in BeMyWords (simplest — no conversion either direction).
- Keep dot names in BeMyWords AND maintain a mapping file in the script.
Pluralization
Android uses <plurals> for pluralization:
<plurals name="apples">
<item quantity="one">%d apple</item>
<item quantity="other">%d apples</item>
</plurals>
Store plural variants in BeMyWords as dot-keyed siblings (apples.one, apples.other) and write a second generator pass that emits <plurals> blocks. This is not covered by the basic script above.
API reference
Troubleshooting
AAPT: error: Invalid file name: your values-<qualifier> folder doesn't match Android conventions. Norwegian is values-nb, Brazilian Portuguese is values-pt-rBR (region prefixed with r). Reference.
error: resource string/<name> not found: the BeMyWords key got filtered out by toAndroidName (starts with digit, contains uppercase, etc.). Rename the key in BeMyWords to fit Android conventions.
HTML entities double-escaped: if you have HTML in your translations, make sure escapeXmlText only runs once. Android's CDATA support may be easier for strings that are unavoidably full of HTML.