andea_revanced-patches/xml_tools/handlers/remove_unused_strings.py

361 lines
13 KiB
Python

"""Remove unused strings."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as DefusedET
from config.settings import Settings
from utils.xml_processor import XMLProcessor
logger = logging.getLogger("xml_tools")
# Constants
BLACKLISTED_STRINGS: set[str] = {
"revanced_extended_audio_tracks_unavailable",
"revanced_remember_video_quality_mobile",
"revanced_remember_video_quality_wifi",
"revanced_sb_api_url_sum",
"revanced_sb_auto_hide_skip_button_duration_sum",
"revanced_sb_enabled",
"revanced_sb_enabled_sum",
"revanced_sb_toast_on_skip",
"revanced_sb_toast_on_skip_sum",
"revanced_spoof_streaming_data_type_entry_android_creator",
"revanced_third_party_youtube_music_not_installed_dialog_title",
"revanced_whitelist_playback_speed",
"revanced_whitelist_sponsor_block",
}
PREFIX_TO_IGNORE: tuple[str, ...] = (
"revanced_gemini_error_already_running_",
"revanced_gemini_loading_",
"revanced_icon_",
"revanced_remember_video_quality_",
"revanced_sb_",
"revanced_shorts_custom_actions_",
"revanced_spoof_app_version_for_lyrics_target_entry_",
"revanced_spoof_app_version_target_entry_",
"revanced_spoof_streaming_data_side_effects_",
)
# Files to clean up if an unused string is found
TARGET_XML_FILES: tuple[str, ...] = (
"forced_strings.xml",
"missing_strings.xml",
"strings.xml",
"updated_strings.xml",
)
settings_instance = Settings()
SCRIPT_DIR = settings_instance.BASE_DIR
SEARCH_DIRECTORIES = [str(SCRIPT_DIR.parent / "revanced-patches")]
ALLOWED_EXTENSIONS = (".kt", ".java", ".xml")
def get_base_name(name: str) -> str:
"""Return the base name by stripping known suffixes from a string name.
Args:
name (str): The original string name.
Returns:
str: The string name with known suffixes removed.
Example:
>>> get_base_name("my_setting_summary_on")
'my_setting'
"""
suffixes = ["_title", "_summary_off", "_summary_on", "_summary"]
for suffix in suffixes:
if name.endswith(suffix):
return name[: -len(suffix)]
return name
def search_in_files(directories: list[str], name_values: set[str]) -> dict[str, list[str]]:
"""Search for string names in all files within specified directories.
Args:
directories (list[str]): list of directory paths to search.
name_values (set[str]): set of string names to search for.
Returns:
dict[str, list[str]]: Dictionary mapping each string name to a list of file paths where it was found.
Raises:
OSError: If there are problems accessing the directories or files.
UnicodeDecodeError: If a file cannot be read as UTF-8.
Notes:
- Skips hidden directories and 'build' directories
- Ignores files defined in TARGET_XML_FILES to prevent false positives
- Only searches files with extensions defined in ALLOWED_EXTENSIONS
- Searches for both original names and their base forms (without suffixes)
"""
results: dict[str, list[str]] = {name: [] for name in name_values}
for directory in directories:
abs_dir = Path(directory).resolve()
logger.debug("Searching in directory: %s (exists: %s)", abs_dir, Path(abs_dir).exists())
for root, dirs, files in os.walk(directory):
# Skip hidden and build directories
dirs[:] = [d for d in dirs if not d.startswith(".") and d != "build"]
for file in files:
# Skip the target definition files so we don't count definitions as usage
if file in TARGET_XML_FILES or not file.endswith(ALLOWED_EXTENSIONS):
continue
file_path = Path(root) / file
try:
with file_path.open(encoding="utf-8") as f:
content = f.read()
for name in name_values:
# Check both original name and base name
if name in content or get_base_name(name) in content:
results[name].append(str(file_path))
except Exception:
logger.exception("Error reading %s: ", file_path)
return results
def should_remove(name: str, unused_names: set[str]) -> bool:
"""Determine if a string should be removed based on various criteria.
Args:
name (str): The string name to check.
unused_names (set[str]): set of string names that were not found in any source files.
Returns:
bool: True if the string should be removed, False otherwise.
Notes:
A string should be removed if:
- The string or its base name is in the unused_names set
- The string is not in BLACKLISTED_STRINGS
- The string does not start with any prefix in PREFIX_TO_IGNORE
"""
base_name = get_base_name(name)
return (
(name in unused_names or base_name in unused_names)
and name not in BLACKLISTED_STRINGS
and not any(name.startswith(prefix) for prefix in PREFIX_TO_IGNORE)
)
def process_xml_file(file_path: Path, unused_names: set[str]) -> None:
"""Process a single XML file to remove unused strings.
Args:
file_path (Path): Path to the XML file to process.
unused_names (set[str]): set of string names that should be considered for removal.
Notes:
- Creates a new XML tree containing only the strings that should be kept
- Only writes the file if strings were actually removed
- Maintains original XML structure and attributes
"""
try:
_, _, strings_dict = XMLProcessor.parse_file(file_path)
# Count strings before removal
initial_count = len(strings_dict)
# Create new root with only used strings
new_root = ET.Element("resources")
kept_strings = 0
for name, data in sorted(strings_dict.items()):
if not should_remove(name, unused_names):
string_elem = DefusedET.fromstring(data["text"])
new_root.append(string_elem)
kept_strings += 1
# Only write if strings were actually removed
if kept_strings < initial_count:
XMLProcessor.write_file(file_path, new_root)
logger.debug(
"Updated %s: removed %s strings, kept %s strings",
file_path,
initial_count - kept_strings,
kept_strings,
)
else:
logger.debug("No changes needed for %s", file_path)
except Exception:
logger.exception("Error processing %s: ", file_path)
def find_string_usage(string_name: str) -> None:
"""Search for a specific string in files and print where it is found.
Args:
string_name (str): The string name to search for.
"""
search_results = search_in_files(SEARCH_DIRECTORIES, {string_name})
files = search_results.get(string_name, [])
if files:
logger.info("String '%s' found in: %s", string_name, files)
else:
logger.info("String '%s' not found in any files.", string_name)
def validate_translation_strings(app: str) -> None:
"""Check if strings in translation directories exist in the main strings.xml.
Args:
app (str): The application identifier to process.
"""
settings = Settings()
base_path = settings.get_resource_path(app, "settings")
source_path = base_path / "host/values/strings.xml"
translations = settings.get_resource_path(app, "translations")
try:
# Get source strings from main strings.xml
_, _, source_strings = XMLProcessor.parse_file(source_path)
source_keys = set(source_strings.keys())
# Check each translation file
for lang_dir in translations.iterdir():
if lang_dir.is_dir():
trans_path = lang_dir / "strings.xml"
if trans_path.exists():
_, _, trans_strings = XMLProcessor.parse_file(trans_path)
trans_keys = set(trans_strings.keys())
extra_strings = trans_keys - source_keys
if extra_strings:
logger.info(
"Translation file %s contains strings not in main strings.xml: %s",
trans_path,
sorted(extra_strings),
)
else:
logger.info("All strings in %s exist in main strings.xml", trans_path)
except Exception:
logger.exception("Error during translation validation: ")
def remove_extra_translation_strings(app: str) -> None:
"""Remove strings in translation directories that don't exist in the main strings.xml.
Args:
app (str): The application identifier to process.
"""
logger.info("Starting process: Remove Unused Strings (Extra Validation)")
settings = Settings()
base_path = settings.get_resource_path(app, "settings")
source_path = base_path / "host/values/strings.xml"
translations = settings.get_resource_path(app, "translations")
try:
# Get source strings from main strings.xml
_, _, source_strings = XMLProcessor.parse_file(source_path)
source_keys = set(source_strings.keys())
# Check and update each translation file
for lang_dir in translations.iterdir():
if lang_dir.is_dir():
trans_path = lang_dir / "strings.xml"
if trans_path.exists():
_, _, trans_strings = XMLProcessor.parse_file(trans_path)
trans_keys = set(trans_strings.keys())
extra_strings = trans_keys - source_keys
if extra_strings:
# Create new root with only valid strings
new_root = ET.Element("resources")
kept_strings = 0
for name, data in sorted(trans_strings.items()):
if name in source_keys:
string_elem = DefusedET.fromstring(data["text"])
new_root.append(string_elem)
kept_strings += 1
# Write updated file
XMLProcessor.write_file(trans_path, new_root)
logger.debug(
"Updated %s: removed %s strings, kept %s strings",
trans_path,
len(extra_strings),
kept_strings,
)
else:
logger.debug("No extra strings to remove in %s", trans_path)
except Exception:
logger.exception("Error during translation string removal: ")
def process(app: str) -> None:
"""Remove unused strings from XML files for a given application.
Args:
app (str): The application identifier to process.
Notes:
- Reads the main 'host/values/strings.xml' to identify all valid string keys.
- Checks if these strings are used in the codebase.
- If a string is unused:
1. Removes it from the main 'host/values/strings.xml'.
2. Iterates through all translation folders and removes it from
any of the TARGET_XML_FILES found there.
"""
settings = Settings()
base_path = settings.get_resource_path(app, "settings")
main_strings_path = base_path / "host/values/strings.xml"
translations_dir = settings.get_resource_path(app, "translations")
try:
if not main_strings_path.exists():
logger.warning("Main strings.xml not found at %s", main_strings_path)
return
# Get candidates from main strings.xml
_, _, source_strings = XMLProcessor.parse_file(main_strings_path)
candidate_keys = set(source_strings.keys())
if not candidate_keys:
logger.info("No strings found in main strings.xml")
return
# Check usage of these candidates
search_results = search_in_files(SEARCH_DIRECTORIES, candidate_keys)
unused_names = {name for name, files in search_results.items() if not files}
if unused_names:
logger.info(
"Found %d unused strings. Processing removal...",
len(unused_names),
)
# Remove unused strings from the main host file
process_xml_file(main_strings_path, unused_names)
# Remove unused strings from all target files in all translation directories
for lang_dir in translations_dir.iterdir():
if lang_dir.is_dir():
for target_filename in TARGET_XML_FILES:
target_file = lang_dir / target_filename
if target_file.exists():
process_xml_file(target_file, unused_names)
remove_extra_translation_strings(app)
except Exception:
logger.exception("Error during processing: ")