with one click
find-missing-translations
// Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
// Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems).
Use when writing or reviewing Jetpack Compose layout APIs, modifier parameters, modifier chain construction, hardcoded root layout decisions, or layout wrappers around a single conditional. Technique-layer skill — complements the codebase-specific compose-expert.
Use when investigating Jetpack Compose recomposition performance, skippable/restartable composables, composables.txt or compiler reports, Layout Inspector recomposition counts, or frame-rate State reads in composition vs layout/draw, and it is not yet clear whether the cause is parameter stability or deferred reads. Technique-layer skill — complements the codebase-specific compose-expert.
Use when writing or reviewing Jetpack Compose code with LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, snapshotFlow, snackbar, navigation, focus requests, analytics, or event Flow collection. Technique-layer skill — complements the codebase-specific compose-expert.
Use when designing or reviewing a reusable Jetpack Compose component whose visual regions vary by caller, or when primitive content parameters and boolean shape flags are accumulating. Technique-layer skill — complements the codebase-specific compose-expert.
| name | find-missing-translations |
| description | Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language |
Extract string resource keys from the default values/strings.xml that are absent in a target locale's strings.xml, excluding non-translatable entries. Outputs missing keys and offers to translate them.
This repo syncs translations via Crowdin (branch l10n_crowdin_translations). Crowdin's default export behavior omits any translation that exactly equals the source, so a key that the translator deliberately kept as English (common for brand terms like "Nowhere Drop", single-word loanwords like "Apps" / "Feed" / "Issues", or version prefixes like "v%1$s") will not appear in the locale's strings.xml even though the Crowdin UI shows it as 100% translated.
Consequences for this skill:
values/strings.xml at runtime, so the user already sees the correct text. Local additions will be silently overwritten on Crowdin's next sync anyway.The Step 2.5 filter below uses the most recent Crowdin export commit reachable from HEAD (subject: "New Crowdin translations by GitHub Action") as the cutoff: any key added to values/strings.xml after that commit is genuinely new (Crowdin hasn't exported it yet); anything older is Crowdin's responsibility regardless of why it's missing. The reachable-from-HEAD check survives the common workflow of deleting the l10n_crowdin_translations branch after merging.
The default set of locales (unless the user specifies otherwise):
| Locale | Language | Directory |
|---|---|---|
cs-rCZ | Czech | values-cs-rCZ |
pt-rBR | Brazilian Portuguese | values-pt-rBR |
sv-rSE | Swedish | values-sv-rSE |
de-rDE | German | values-de-rDE |
Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
Always diff against cs-rCZ first — it is the most complete locale and serves as the reference. Any keys missing in cs-rCZ will also be missing in the other target locales.
You MUST diff both <string name= AND <plurals name= — these are independent resource types and a key that is a <plurals> in the source will never appear in a <string> diff. Forgetting <plurals> is the most common silent failure of this skill (it misses things like music_playlist_track_count, notification_count_more, etc.).
# Strings: extract translatable keys from default (exclude translatable="false")
echo "=== missing <string> ==="
comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
# Plurals: a separate resource type — MUST be diffed independently
echo "=== missing <plurals> ==="
comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
This gives two lists of missing key names — keep them separate; <plurals> translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care"). Do NOT diff each locale separately for strings — assume the same keys are missing in all target locales (but DO repeat the <plurals> diff per locale if you suspect Crowdin asymmetric stripping).
Caveat: Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys). If the cs-rCZ list looks suspiciously short, run the same diff for each target locale individually and union the results before Step 2.5.
A missing key is only actionable if Crowdin has never exported it. Once a key has been pushed to Crowdin and exported back, the translator may have chosen "use English" — Crowdin stores that choice in its own database and strips the entry from the exported strings.xml. From disk we cannot tell "translator picked English" from "Crowdin never saw the key": both look identical.
The reliable signal is time: compare when the key was added to values/strings.xml against the timestamp of the most recent Crowdin export that has been merged into the current branch. Crowdin's GitHub Action produces commits with the literal subject New Crowdin translations by GitHub Action; finding the latest such commit reachable from HEAD works even if the l10n_crowdin_translations branch has been deleted post-merge (a common cleanup workflow).
# Latest Crowdin export reachable from HEAD (survives branch deletion).
sync_ts=$(git log -1 --format=%ct --grep='^New Crowdin translations by GitHub Action$' 2>/dev/null)
if [ -z "$sync_ts" ]; then
echo "WARNING: no Crowdin export commit found in history; treating all missing as actionable" >&2
sync_ts=0
else
echo "Crowdin sync cutoff: $(git log -1 --format='%ci %h' --grep='^New Crowdin translations by GitHub Action$')"
fi
# For each locale, list only keys added after the Crowdin sync (truly new).
# Run for BOTH <string> and <plurals>.
for locale in cs-rCZ de-rDE sv-rSE; do
echo "=== $locale: genuinely new (post-sync) <string> keys ==="
comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
| while IFS= read -r key; do
added_ts=$(git log -1 --format=%ct -S "name=\"$key\"" -- amethyst/src/main/res/values/strings.xml)
if [ -n "$added_ts" ] && [ "$added_ts" -gt "$sync_ts" ]; then
echo "$key"
fi
done
echo "=== $locale: genuinely new (post-sync) <plurals> keys ==="
comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
| while IFS= read -r key; do
added_ts=$(git log -1 --format=%ct -S "name=\"$key\"" -- amethyst/src/main/res/values/strings.xml)
if [ -n "$added_ts" ] && [ "$added_ts" -gt "$sync_ts" ]; then
echo "$key"
fi
done
done
Why this beats using the l10n_crowdin_translations branch tip:
values-*/strings.xml.Only the listed (post-sync) keys are actionable. Anything older is either:
Nowhere X, loanwords like Apps / Feed / Issues, version prefixes like v%1$s), orIn both cases, Android's resource resolution falls back to values/strings.xml at runtime, so there is no user-visible bug. Adding source-identical fallbacks locally is noise that the next sync will strip again.
Report the pre-sync skipped count as a one-liner ("N keys predate the last Crowdin sync, skipped — Crowdin owns them"). Do not list them or propose translations.
If no Crowdin export commit can be found in history (sync_ts=0 fallback), warn the user and fall back to treating all missing keys as actionable — but flag that the workflow is degraded.
For each missing key, extract its English value. <string> is a single line; <plurals> is a multi-line block — handle each appropriately.
# Missing <string>: full line from default strings.xml
while IFS= read -r key; do
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# Missing <plurals>: extract the multi-line block (opening tag through </plurals>)
while IFS= read -r key; do
awk -v key="$key" '
$0 ~ "<plurals name=\"" key "\"" { in_p = 1 }
in_p { print }
in_p && /<\/plurals>/ { in_p = 0 }
' amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
Before presenting results, scan the missing English strings for two red-flag patterns and warn the user about each match:
"1" next to a noun. A new English string like "1 reply", "1 follower", or "1 minute ago" almost always belongs in a <plurals> resource — not a <string>. Hardcoding 1 in English forces every translator to either also hardcode 1 (breaking languages where the one category covers other numbers, e.g. some Slavic languages) or to silently change the meaning.%d / %1$d placeholder in a clearly singular/plural sentence (e.g. "%1$d reply", "%d follower"). Even though the placeholder is parameterised, English-only one/other agreement won't survive translation into languages that need few/many.Also audit existing <plurals> resources for two anti-patterns:
quantity="one" items that hardcode the literal 1 (instead of using a %d / %1$d placeholder) — broken for languages where the one CLDR category covers more than just n=1 (Russian, Ukrainian, Croatian, etc.).quantity="zero" items in any locale that doesn't natively use the zero CLDR category — i.e. everything except Arabic (ar) and Welsh (cy). ICU/CLDR maps count=0 to other for English and all the locales we ship to (cs, de, pt-BR, sv, etc.), so <item quantity="zero"> is dead code there: getQuantityString(id, 0) will pick other, never the zero entry, and the visible runtime string ends up "…0 items" instead of the intended "…no items".If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site if (count == 0) branch to a separate <string>, not a quantity="zero" plural item.
Flag and offer to fix:
# Scan every locale's strings.xml for <item quantity="one"> entries that
# hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="one"/ {
# Extract item text (between > and <)
text = $0; sub(/^[^>]*>/, "", text); sub(/<.*$/, "", text)
# Flag if it contains a digit AND no %d / %1$d placeholder
if (text ~ /[0-9]/ && text !~ /%[0-9]*\$?d/) {
print file ": <plurals name=\"" name "\"> one=\"" text "\""
}
}
/<\/plurals>/ { in_plurals = 0 }
' "$f"
done
Then scan for dead quantity="zero" entries. CLDR's zero category is integer-bearing only in Arabic (ar) and Welsh (cy). In every other locale, count=0 falls through to other, so a <item quantity="zero"> entry is dead and likely a translator/author bug (or it silently never fires):
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
# Skip Arabic and Welsh — they natively use the zero category.
case "$f" in
*values-ar*|*values-cy*) continue ;;
esac
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="zero"/ {
text = $0; sub(/^[^>]*>/, "", text); sub(/<.*$/, "", text)
print file ": <plurals name=\"" name "\"> zero=\"" text "\""
}
/<\/plurals>/ { in_plurals = 0 }
' "$f"
done
For each hit, warn the user that the entry is unreachable in that locale. The fix is to remove the <item quantity="zero"> and, if the UX wanted distinct wording for count=0, add a separate <string> plus an if (count == 0) branch at the call site (see "Plurals: handle with care" below).
Quick scan over the missing keys:
# Flag missing English values that look like they should be <plurals>
while IFS= read -r key; do
line=$(grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml)
# Hardcoded standalone "1" (word-boundary), or a count placeholder followed by a likely-countable noun
if echo "$line" | grep -qE '>([^<]*\b1\b[^<]*|[^<]*%[0-9]*\$?d[^<]*)<'; then
echo "PLURAL CANDIDATE: $line"
fi
done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
The regex is intentionally noisy — review each hit by hand. Many %d strings (e.g. "Limits for kind %1$d", "Max event size (bytes)") are not plural-bearing. Only flag the ones whose surrounding noun changes form with the count.
For each genuine match, stop and warn the user before translating, e.g.:
⚠️
notification_countis"1 new reply"— this hardcodes"1"and should likely be a<plurals>resource (e.g.quantity="one"→"%d new reply",quantity="other"→"%d new replies"). Convert before translating?
Do not silently translate plural-shaped <string> entries; the wrong shape will then need to be fixed in every locale.
Output the missing entries as raw XML resource lines (copy-paste ready):
<string name="attestation_valid">Valid</string>
<string name="attestation_valid_from">Valid from %1$s</string>
<string name="feed_group_lists">Lists</string>
Also check <string-array> and <plurals> tags using the same approach if the project uses them.
When adding or proposing <plurals> entries, follow these rules:
"1" in the English text of a quantity="one" item. Use the format placeholder (e.g. %1$d / %d) so the runtime substitutes the actual count. Hardcoding "1" breaks every language whose one category covers numbers other than 1 (e.g. some Slavic languages).one + other is enough. CLDR plural categories vary by language: zero, one, two, few, many, other. Always include every category the target language uses, not just the categories present in English. Examples:
en): one, othercs): one, few, many, otherpl): one, few, many, otherru): one, few, many, otherar): zero, one, two, few, many, otherone, other<plurals> resource rather than a single <string>. Surface this to the user before proposing translations.quantity="zero" outside Arabic (ar) and Welsh (cy). CLDR's zero category is integer-bearing only in those two languages. Android calls PluralRules.select(0) for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns other, so the explicit <item quantity="zero"> is never picked at runtime and the user sees "…0 items" instead of the intended wording. If the design calls for "no items" at count=0, model it as a separate <string> and an if (count == 0) branch at the call site:
val label = if (count == 0) {
stringRes(R.string.foo_no_items, dateLabel)
} else {
pluralStringResource(R.plurals.foo_items, count, dateLabel, count)
}
<plurals> docs and CLDR plural rules.Then ask the user: "Would you like me to translate these missing strings into [list of target locales]?"
When adding translated strings to locale files:
</resources> tag.translatable="false" — these should never appear in locale files<string name= — <plurals> is a separate resource type; a source <plurals> missing from a locale will never show up in a <string> diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for <string-array> if the project uses it.values/strings.xml after the last l10n_crowdin_translations sync are genuinely new.values/strings.xml at runtime anyway, so there is no user-visible bug to fix."1" in a <plurals> quantity="one" item — always use the count placeholder; otherwise non-English one categories produce wrong textone/other set into every locale — each language must include all CLDR plural categories it uses (e.g. Czech needs one, few, many, other)<item quantity="zero"> to special-case count=0 — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → other, so the runtime never picks the zero item and the user sees "…0 items". Special-case at the call site with a separate <string> instead.