From ef04613f4809dbc801ea418a7ee88b0926a6dffe Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 17 Jun 2026 16:54:52 -0400 Subject: [PATCH 01/11] Add android_prune_orphaned_translations action Removes // entries from values-*/strings.xml whose key is not declared in the source strings (the res dir's default values/strings.xml, optionally unioned with additional_source_strings_paths). Useful after downloading translations from GlotPress to avoid Lint ExtraTranslation errors from keys removed/renamed since the source was synced. --- CHANGELOG.md | 2 +- ...roid_prune_orphaned_translations_action.rb | 116 ++++++++++++++++++ ...ndroid_prune_orphaned_translations_spec.rb | 110 +++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb create mode 100644 spec/android_prune_orphaned_translations_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4690cb0ef..f38e80a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Added `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose key is not declared in the source strings (the res dir's default `values/strings.xml`, optionally unioned with `additional_source_strings_paths`). Useful after downloading translations from GlotPress to avoid Lint `ExtraTranslation` errors from keys removed/renamed since the source was last synced. ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb new file mode 100644 index 000000000..ea905b9e4 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'nokogiri' + +module Fastlane + module Actions + class AndroidPruneOrphanedTranslationsAction < Action + def self.run(params) + res_dir = params[:res_dir] + source_paths = [File.join(res_dir, 'values', 'strings.xml')] + params[:additional_source_strings_paths] + valid_keys = collect_keys(source_paths) + + locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')) + total_pruned = 0 + + locale_files.each do |file| + pruned = prune_file(file: file, valid_keys: valid_keys) + next if pruned.empty? + + total_pruned += pruned.count + UI.message("Pruned #{pruned.count} orphaned entries from `#{file}`: #{pruned.join(', ')}") + end + + UI.success("Pruned #{total_pruned} orphaned translation entries across #{locale_files.count} locale file(s).") + total_pruned + end + + # Collects the set of resource names (string, string-array, plurals, …) declared in the given strings files. + # + # @param [Array] paths The strings.xml files to read the valid keys from. + # @return [Set] The set of declared resource names. + def self.collect_keys(paths) + paths.each_with_object(Set.new) do |path, keys| + doc = File.open(path) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) } + doc.xpath('/resources/*[@name]').each { |node| keys << node['name'] } + end + end + + # Removes from `file` any resource entry whose `name` is not in `valid_keys`, preserving the rest of the + # file's formatting (so the change shows up as a minimal diff). + # + # @param [String] file The locale strings.xml file to prune. + # @param [Set] valid_keys The set of names that are allowed to remain. + # @return [Array] The names of the entries that were pruned. + def self.prune_file(file:, valid_keys:) + doc = File.open(file) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) } + orphans = doc.xpath('/resources/*[@name]').reject { |node| valid_keys.include?(node['name']) } + return [] if orphans.empty? + + names = orphans.map { |node| node['name'] } + orphans.each do |node| + # Drop the indentation/newline text node right before the element too, to avoid leaving a blank line. + previous = node.previous_sibling + previous.remove if previous&.text? && previous.text.strip.empty? + node.remove + end + + File.open(file, 'w') { |f| doc.write_to(f, encoding: Encoding::UTF_8.to_s, indent: 4) } + names + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Removes translations whose key is not present in the source strings, to avoid Lint `ExtraTranslation` errors' + end + + def self.details + <<~DETAILS + When downloading translations from GlotPress, the export may include keys that are no longer present in + the app's source strings (e.g. removed or renamed since the GlotPress source was last synced). Android + Lint flags these orphaned translations as `ExtraTranslation` errors. + + This action removes — from every `values-*/strings.xml` under `res_dir` — any ``, `` + or `` entry whose `name` is not declared in the default `values/strings.xml` of `res_dir`, + optionally unioned with `additional_source_strings_paths` (useful when a product flavor overlays a base + module's resources at build time, so the base module's keys are valid too). + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :res_dir, + description: "Path to the Android project's `res` directory containing the `values-*` locale subdirectories to prune", + type: String, + optional: false + ), + FastlaneCore::ConfigItem.new( + key: :additional_source_strings_paths, + description: 'Paths to additional default `strings.xml` files whose keys should also be treated as valid ' \ + '(e.g. a base module that the pruned `res_dir` overlays at build time)', + type: Array, + optional: true, + default_value: [] + ), + ] + end + + def self.return_value + 'The total number of orphaned translation entries that were pruned' + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + platform == :android + end + end + end +end diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb new file mode 100644 index 000000000..1ab659c8b --- /dev/null +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +describe Fastlane::Actions::AndroidPruneOrphanedTranslationsAction do + # Writes `content` to `path`, creating intermediate directories. + def write_file(path, content) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + # A default `values/strings.xml` declaring `hello`, `bye`, an array and a plural. + let(:default_strings) do + <<~XML + + + Hello + Bye + + Earth + + + %d item + %d items + + + XML + end + + it 'removes only the entries whose key is not in the default strings, keeping the rest intact' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + write_file(fr_file, <<~XML) + + + Bonjour + Orphelin + Au revoir + + %d truc + %d trucs + + + XML + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(2) + content = File.read(fr_file) + expect(content).to include('name="hello"', 'name="bye"') + expect(content).not_to include('orphan_string', 'orphan_plural') + # No blank line left behind where the orphaned was removed. + expect(content).not_to match(/\n[[:space:]]*\n[[:space:]]* + + Flavor + + XML + base_strings = File.join(dir, 'base', 'values', 'strings.xml') + write_file(base_strings, default_strings) + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + write_file(fr_file, <<~XML) + + + Saveur + Bonjour + Orphelin + + XML + + pruned = run_described_fastlane_action(res_dir: res_dir, additional_source_strings_paths: [base_strings]) + + expect(pruned).to eq(1) + content = File.read(fr_file) + expect(content).to include('name="flavor_only"', 'name="hello"') + expect(content).not_to include('orphan_string') + end + end + + it 'does nothing and reports zero when there are no orphaned entries' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + fr_content = <<~XML + + + Bonjour + Au revoir + + XML + write_file(fr_file, fr_content) + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(0) + expect(File.read(fr_file)).to eq(fr_content) + end + end +end From e1fbe30c1d11776bc53a52c731022ba1b153f3a1 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 09:13:42 -0400 Subject: [PATCH 02/11] Address Copilot review on android_prune_orphaned_translations Add explicit `require 'fileutils'` to the spec (matches existing specs and is robust if spec_helper's transitive load changes). Declined the suggested `require 'set'` in the action: Set is autoloaded on Ruby >= 3.2 (the gem's floor) and rubocop's Lint/RedundantRequireStatement would reject it. --- spec/android_prune_orphaned_translations_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index 1ab659c8b..727c04e57 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' require 'tmpdir' +require 'fileutils' describe Fastlane::Actions::AndroidPruneOrphanedTranslationsAction do # Writes `content` to `path`, creating intermediate directories. From efb03ccbadbb7974227ba15ba00320de0a86d740 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 09:39:04 -0400 Subject: [PATCH 03/11] Only prune locale qualifier dirs, not values-night/v21/etc. Restrict the glob to Android locale-qualifier directories (language, optional region, or BCP-47 b+ form) so non-locale qualifier dirs like values-night or values-v21 are left untouched. Addresses Copilot review on #734. --- ...roid_prune_orphaned_translations_action.rb | 9 +++++- ...ndroid_prune_orphaned_translations_spec.rb | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb index ea905b9e4..ed82be875 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -6,12 +6,19 @@ module Fastlane module Actions class AndroidPruneOrphanedTranslationsAction < Action + # Matches an Android `values-` directory whose qualifier is a locale (a language code, optional + # region, or a BCP-47 `b+` form), so non-locale qualifier dirs (e.g. `values-night`, `values-v21`, + # `values-land`) are left untouched. + LOCALE_VALUES_DIR_REGEX = /\Avalues-(?:b\+[a-zA-Z]+(?:\+[a-zA-Z0-9]+)*|[a-z]{2,3}(?:-r(?:[A-Z]{2}|\d{3}))?)\z/ + def self.run(params) res_dir = params[:res_dir] source_paths = [File.join(res_dir, 'values', 'strings.xml')] + params[:additional_source_strings_paths] valid_keys = collect_keys(source_paths) - locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')) + locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')).select do |file| + File.basename(File.dirname(file)).match?(LOCALE_VALUES_DIR_REGEX) + end total_pruned = 0 locale_files.each do |file| diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index 727c04e57..224bd573a 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -58,6 +58,37 @@ def write_file(path, content) end end + it 'leaves non-locale qualifier directories (e.g. values-night) untouched' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + # A non-locale qualifier dir with a key absent from the default must NOT be pruned. + night_file = File.join(res_dir, 'values-night', 'strings.xml') + night_content = <<~XML + + + Night + + XML + write_file(night_file, night_content) + # A real locale dir with an orphan, to confirm pruning still happens there. + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + write_file(fr_file, <<~XML) + + + Bonjour + Orphelin + + XML + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(1) + expect(File.read(night_file)).to eq(night_content) + expect(File.read(fr_file)).not_to include('orphan_string') + end + end + it 'treats keys from `additional_source_strings_paths` as valid (flavor overlay case)' do Dir.mktmpdir do |dir| res_dir = File.join(dir, 'res') From e7789a45bcb67e19c12866e4ad6d6ad00dbfe3bb Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 10:12:39 -0400 Subject: [PATCH 04/11] Validate source strings paths with a clear error Fail with a user-facing message when the res dir's default values/strings.xml or any additional_source_strings_paths entry is missing, instead of a low-level Errno::ENOENT. Addresses Copilot review on #734. --- ...roid_prune_orphaned_translations_action.rb | 3 +++ ...ndroid_prune_orphaned_translations_spec.rb | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb index ed82be875..1e23f1404 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -14,6 +14,9 @@ class AndroidPruneOrphanedTranslationsAction < Action def self.run(params) res_dir = params[:res_dir] source_paths = [File.join(res_dir, 'values', 'strings.xml')] + params[:additional_source_strings_paths] + source_paths.each do |path| + UI.user_error!("Source strings file not found: `#{path}`") unless File.file?(path) + end valid_keys = collect_keys(source_paths) locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')).select do |file| diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index 224bd573a..e51de2b21 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -139,4 +139,24 @@ def write_file(path, content) expect(File.read(fr_file)).to eq(fr_content) end end + + it 'raises a clear error when the res dir has no default strings file' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + FileUtils.mkdir_p(res_dir) + expect do + run_described_fastlane_action(res_dir: res_dir) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Source strings file not found/) + end + end + + it 'raises a clear error when an additional source strings path is missing' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + expect do + run_described_fastlane_action(res_dir: res_dir, additional_source_strings_paths: [File.join(dir, 'missing.xml')]) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Source strings file not found/) + end + end end From b6ac1db8d0f6bbb310e35ef607ac28b04ad6bcd6 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 10:21:26 -0400 Subject: [PATCH 05/11] Add PR reference to the CHANGELOG entry [#734] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f38e80a4e..f238e2862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- Added `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose key is not declared in the source strings (the res dir's default `values/strings.xml`, optionally unioned with `additional_source_strings_paths`). Useful after downloading translations from GlotPress to avoid Lint `ExtraTranslation` errors from keys removed/renamed since the source was last synced. +- Added `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose key is not declared in the source strings (the res dir's default `values/strings.xml`, optionally unioned with `additional_source_strings_paths`). Useful after downloading translations from GlotPress to avoid Lint `ExtraTranslation` errors from keys removed/renamed since the source was last synced. [#734] ### Bug Fixes From 09939a07396ee50073c49f6fdd754761f12345fd Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Fri, 26 Jun 2026 11:06:30 +1000 Subject: [PATCH 06/11] Add `values-car` pruning regression test Android's `car` qualifier represents UI mode, not a locale, so the pruning action should leave `values-car` resources untouched. This is intentionally test-only coverage; the production fix is not included. --- Generated with the help of Codex, https://openai.com/codex Co-Authored-By: Codex GPT-5 --- ...ndroid_prune_orphaned_translations_spec.rb | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index e51de2b21..3ae43e5b2 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -89,6 +89,26 @@ def write_file(path, content) end end + it 'leaves car UI mode qualifier directories untouched' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + car_file = File.join(res_dir, 'values-car', 'strings.xml') + car_content = <<~XML + + + Car + + XML + write_file(car_file, car_content) + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(0) + expect(File.read(car_file)).to eq(car_content) + end + end + it 'treats keys from `additional_source_strings_paths` as valid (flavor overlay case)' do Dir.mktmpdir do |dir| res_dir = File.join(dir, 'res') From 2a7813e4c85e24041e9c15d472a3bca980cd5f66 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 29 Jun 2026 13:23:27 -0400 Subject: [PATCH 07/11] Validate source strings paths in verify_block rather than in run Move the existence checks for the res dir's default `values/strings.xml` and each `additional_source_strings_paths` entry into `verify_block` procs on their config items, matching the repo convention for input path validation (e.g. `ios_extract_keys_from_strings_files`). --- ...android_prune_orphaned_translations_action.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb index 1e23f1404..b2c7bfd89 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -14,9 +14,6 @@ class AndroidPruneOrphanedTranslationsAction < Action def self.run(params) res_dir = params[:res_dir] source_paths = [File.join(res_dir, 'values', 'strings.xml')] + params[:additional_source_strings_paths] - source_paths.each do |path| - UI.user_error!("Source strings file not found: `#{path}`") unless File.file?(path) - end valid_keys = collect_keys(source_paths) locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')).select do |file| @@ -97,7 +94,11 @@ def self.available_options key: :res_dir, description: "Path to the Android project's `res` directory containing the `values-*` locale subdirectories to prune", type: String, - optional: false + optional: false, + verify_block: proc do |value| + source_path = File.join(value, 'values', 'strings.xml') + UI.user_error!("Source strings file not found: `#{source_path}`") unless File.file?(source_path) + end ), FastlaneCore::ConfigItem.new( key: :additional_source_strings_paths, @@ -105,7 +106,12 @@ def self.available_options '(e.g. a base module that the pruned `res_dir` overlays at build time)', type: Array, optional: true, - default_value: [] + default_value: [], + verify_block: proc do |value| + value.each do |path| + UI.user_error!("Source strings file not found: `#{path}`") unless File.file?(path) + end + end ), ] end From 4dad49b68309a6754640f9893fb57d21406d9303 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 29 Jun 2026 13:26:39 -0400 Subject: [PATCH 08/11] Update `AndroidPruneOrphanedTranslationsAction` description per PR suggestion Co-authored-by: Gio Lodi --- .../android/android_prune_orphaned_translations_action.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb index b2c7bfd89..6587e5106 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -72,7 +72,7 @@ def self.prune_file(file:, valid_keys:) ##################################################### def self.description - 'Removes translations whose key is not present in the source strings, to avoid Lint `ExtraTranslation` errors' + 'Removes translations whose keys are not present in the source strings, to avoid Lint `ExtraTranslation` errors' end def self.details From 756965bc6e36e3b37fa71bc4eddfb28734faa889 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 29 Jun 2026 13:27:21 -0400 Subject: [PATCH 09/11] Update `android_prune_orphaned_translations` CHANGELOG entry per PR suggestion Co-authored-by: Gio Lodi --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f238e2862..b40b52e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- Added `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose key is not declared in the source strings (the res dir's default `values/strings.xml`, optionally unioned with `additional_source_strings_paths`). Useful after downloading translations from GlotPress to avoid Lint `ExtraTranslation` errors from keys removed/renamed since the source was last synced. [#734] +- `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose keys are not declared in the source `values/strings.xml optionally unioned with `additional_source_strings_paths`. [#734] ### Bug Fixes From 904e2de3ec69c9c9649e6f9981f0175298ef819e Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 29 Jun 2026 13:37:38 -0400 Subject: [PATCH 10/11] DRY the default source strings path into constants Extract the repeated `values/strings.xml` path into DEFAULT_SOURCE_STRINGS_FILE_NAME and DEFAULT_SOURCE_STRINGS_RELATIVE_PATH, and use them in run, the locale glob, and the res_dir verify_block. --- CHANGELOG.md | 2 +- .../android_prune_orphaned_translations_action.rb | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b40b52e7a..43b9d6e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose keys are not declared in the source `values/strings.xml optionally unioned with `additional_source_strings_paths`. [#734] +- `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose keys are not declared in the source `values/strings.xml` optionally unioned with `additional_source_strings_paths`. [#734] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb index 6587e5106..0bd0e1bb0 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -11,12 +11,15 @@ class AndroidPruneOrphanedTranslationsAction < Action # `values-land`) are left untouched. LOCALE_VALUES_DIR_REGEX = /\Avalues-(?:b\+[a-zA-Z]+(?:\+[a-zA-Z0-9]+)*|[a-z]{2,3}(?:-r(?:[A-Z]{2}|\d{3}))?)\z/ + DEFAULT_SOURCE_STRINGS_FILE_NAME = 'strings.xml' + DEFAULT_SOURCE_STRINGS_RELATIVE_PATH = File.join('values', DEFAULT_SOURCE_STRINGS_FILE_NAME).freeze + def self.run(params) res_dir = params[:res_dir] - source_paths = [File.join(res_dir, 'values', 'strings.xml')] + params[:additional_source_strings_paths] + source_paths = [File.join(res_dir, DEFAULT_SOURCE_STRINGS_RELATIVE_PATH)] + params[:additional_source_strings_paths] valid_keys = collect_keys(source_paths) - locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')).select do |file| + locale_files = Dir.glob(File.join(res_dir, 'values-*', DEFAULT_SOURCE_STRINGS_FILE_NAME)).select do |file| File.basename(File.dirname(file)).match?(LOCALE_VALUES_DIR_REGEX) end total_pruned = 0 @@ -96,7 +99,7 @@ def self.available_options type: String, optional: false, verify_block: proc do |value| - source_path = File.join(value, 'values', 'strings.xml') + source_path = File.join(value, DEFAULT_SOURCE_STRINGS_RELATIVE_PATH) UI.user_error!("Source strings file not found: `#{source_path}`") unless File.file?(source_path) end ), From 56dd66dd179a20dbfc96546b7a7db11d55683f4c Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 29 Jun 2026 12:48:52 -0400 Subject: [PATCH 11/11] Skip Android UI-mode qualifier dirs (e.g. values-car) when pruning The locale-detection regex's 2-3 letter language branch also matches Android UI-mode qualifiers like `values-car`, which would prune valid car-mode resources. `car` is also a valid 3-letter ISO 639 language code and cannot be distinguished from a locale by shape alone, so exclude the documented UI-mode qualifiers explicitly. Changes: - Add UI_MODE_QUALIFIERS exclusion list and apply it in locale detection - Add a values-kmr test covering 3-letter legacy locales - Note locale-only scope in the CHANGELOG entry --- ...roid_prune_orphaned_translations_action.rb | 17 +++++++++---- ...ndroid_prune_orphaned_translations_spec.rb | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb index 0bd0e1bb0..efb850ca5 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -6,11 +6,19 @@ module Fastlane module Actions class AndroidPruneOrphanedTranslationsAction < Action - # Matches an Android `values-` directory whose qualifier is a locale (a language code, optional - # region, or a BCP-47 `b+` form), so non-locale qualifier dirs (e.g. `values-night`, `values-v21`, - # `values-land`) are left untouched. + # Matches an Android `values-` directory whose qualifier is a locale: a 2- or 3-letter language + # code (`fr`, `kmr`), an optional region (`pt-rBR`, `es-r419`), or a BCP-47 `b+` form (`b+sr+Latn`), so + # non-locale qualifier dirs (e.g. `values-night`, `values-v21`, `values-land`) are left untouched. LOCALE_VALUES_DIR_REGEX = /\Avalues-(?:b\+[a-zA-Z]+(?:\+[a-zA-Z0-9]+)*|[a-z]{2,3}(?:-r(?:[A-Z]{2}|\d{3}))?)\z/ + # Android UI-mode qualifiers, which are never locales: + # https://developer.android.com/guide/topics/resources/providing-resources#UiModeQualifier + # `car` is indistinguishable by shape from a 3-letter ISO 639 language code, so `LOCALE_VALUES_DIR_REGEX` + # would otherwise treat `values-car` as a locale and prune its (valid) car-mode resources. There's no purely + # syntactic way to tell the two apart, so we exclude the documented UI-mode qualifiers explicitly. (`car` is + # the only one short enough to currently match the regex; the rest are listed to capture the full set.) + UI_MODE_QUALIFIERS = %w[car desk television appliance watch vrheadset].freeze + DEFAULT_SOURCE_STRINGS_FILE_NAME = 'strings.xml' DEFAULT_SOURCE_STRINGS_RELATIVE_PATH = File.join('values', DEFAULT_SOURCE_STRINGS_FILE_NAME).freeze @@ -20,7 +28,8 @@ def self.run(params) valid_keys = collect_keys(source_paths) locale_files = Dir.glob(File.join(res_dir, 'values-*', DEFAULT_SOURCE_STRINGS_FILE_NAME)).select do |file| - File.basename(File.dirname(file)).match?(LOCALE_VALUES_DIR_REGEX) + dir_name = File.basename(File.dirname(file)) + dir_name.match?(LOCALE_VALUES_DIR_REGEX) && !UI_MODE_QUALIFIERS.include?(dir_name.delete_prefix('values-')) end total_pruned = 0 diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index 3ae43e5b2..9f731e06f 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -109,6 +109,30 @@ def write_file(path, content) end end + # `kmr` (Northern Kurdish) is a real 3-letter legacy locale used by e.g. WordPress-Android, so it must still be + # pruned — guarding against an over-eager "restrict locales to 2 letters" fix for the `values-car` collision. + it 'prunes 3-letter legacy locale directories (e.g. values-kmr)' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + kmr_file = File.join(res_dir, 'values-kmr', 'strings.xml') + write_file(kmr_file, <<~XML) + + + Silav + Sêwî + + XML + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(1) + content = File.read(kmr_file) + expect(content).to include('name="hello"') + expect(content).not_to include('orphan_string') + end + end + it 'treats keys from `additional_source_strings_paths` as valid (flavor overlay case)' do Dir.mktmpdir do |dir| res_dir = File.join(dir, 'res')