android-localisation 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """
2
+ android-localisation: Zero-dependency Android strings.xml translation using LLMs.
3
+ """
4
+
5
+ __version__ = "1.0.0"
@@ -0,0 +1,82 @@
1
+ """
2
+ Unified CLI entry point for android-localisation.
3
+
4
+ Usage:
5
+ android-localise translate --api-key KEY
6
+ android-localise fix
7
+ android-localise verify
8
+ android-localise models
9
+ """
10
+
11
+ import argparse
12
+ from android_localisation import __version__
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser(
17
+ prog="android-localise",
18
+ description="Zero-dependency Android strings.xml localization using LLMs.",
19
+ )
20
+ parser.add_argument("--version", action="version", version=f"android-localisation {__version__}")
21
+
22
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
23
+ subparsers.required = True
24
+
25
+ # --- translate ---
26
+ translate_parser = subparsers.add_parser("translate", help="Translate strings.xml into all locale directories")
27
+ translate_parser.add_argument("--res-dir", default="app/src/main/res", help="Path to the Android res/ directory (default: app/src/main/res)")
28
+ translate_parser.add_argument("--provider", choices=["gemini", "openai", "anthropic", "custom"], default="gemini", help="AI provider (default: gemini)")
29
+ translate_parser.add_argument("--model", help="Any model name supported by the provider (uses provider default if not set)")
30
+ translate_parser.add_argument("--api-key", help="API key (or set GEMINI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY)")
31
+ translate_parser.add_argument("--base-url", help="Custom OpenAI-compatible endpoint URL (required for 'custom' provider)")
32
+ translate_parser.add_argument("--app-context", help="Short description of your app for better translations")
33
+ translate_parser.add_argument("--sleep", type=float, default=5.0, help="Seconds between API requests (default: 5.0)")
34
+
35
+ # --- fix ---
36
+ fix_parser = subparsers.add_parser("fix", help="Fix XML escaping issues in translated strings.xml files")
37
+ fix_parser.add_argument("--res-dir", default="app/src/main/res", help="Path to the Android res/ directory (default: app/src/main/res)")
38
+
39
+ # --- verify ---
40
+ verify_parser = subparsers.add_parser("verify", help="Verify translated strings won't crash the app (requires javac)")
41
+ verify_parser.add_argument("--res-dir", default="app/src/main/res", help="Path to the Android res/ directory (default: app/src/main/res)")
42
+
43
+ # --- models ---
44
+ models_parser = subparsers.add_parser("models", help="List all available models per provider")
45
+ models_parser.add_argument("--provider", choices=["gemini", "openai", "anthropic"], default=None,
46
+ help="Filter by provider (shows all if not set)")
47
+
48
+ args = parser.parse_args()
49
+
50
+ if args.command == "translate":
51
+ from android_localisation.translate import main as run
52
+ run(args)
53
+
54
+ elif args.command == "fix":
55
+ from android_localisation.fix import main as run
56
+ run(args)
57
+
58
+ elif args.command == "verify":
59
+ from android_localisation.verify import main as run
60
+ run(args)
61
+
62
+ elif args.command == "models":
63
+ from android_localisation.translate import PROVIDER_MODELS
64
+ providers = [args.provider] if args.provider else ["gemini", "openai", "anthropic"]
65
+ print()
66
+ for p in providers:
67
+ models = PROVIDER_MODELS.get(p, [])
68
+ print(f" {p.upper()}")
69
+ for i, m in enumerate(models):
70
+ tag = " (default)" if i == 0 else f" (fallback {i})" if i < len(models) - 1 else " (fallback)"
71
+ print(f" {'→' if i == 0 else ' '} {m}{tag}")
72
+ print()
73
+ print(" CUSTOM (Ollama, LM Studio, etc.)")
74
+ print(" → Any model name your local server supports (must use --model)")
75
+ print()
76
+ print(" Tip: use --model to pick any model, e.g:")
77
+ print(" android-localise translate --provider openai --model gpt-4o --api-key KEY")
78
+ print()
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
@@ -0,0 +1,84 @@
1
+ import os
2
+ import re
3
+ import glob
4
+ import argparse
5
+
6
+ # Matches any valid Java/Android format specifier:
7
+ # e.g. %s, %d, %f, %1$s, %2$d, %1$f, %-10s, %+d, etc.
8
+ _FORMAT_SPECIFIER = re.compile(
9
+ r'%(\d+\$)?([-#+ 0,(<]*)?(\d+)?(\.\d+)?([tT]?[a-zA-Z])'
10
+ )
11
+
12
+ # Recognizes valid specifier suffixes so we don't double-escape them
13
+ _VALID_SPECIFIER = re.compile(
14
+ r'%(\d+\$)?([-#+ 0,(<]*)?(\d+)?(\.\d+)?([tT]?[a-zA-Z%])'
15
+ )
16
+
17
+
18
+ def _fix_text(text):
19
+ # Replace unicode curly apostrophes (common LLM hallucination)
20
+ for curly in ('\u2019', '\u2018'):
21
+ text = text.replace(curly, r"\'")
22
+
23
+ # Unescape java-style unicode percent signs so we can process them uniformly
24
+ text = text.replace(r'\u0025', '%')
25
+
26
+ # Escape unescaped apostrophes (negative lookbehind to avoid double-escaping)
27
+ text = re.sub(r"(?<!\\)'", r"\'", text)
28
+
29
+ # Fix % symbols:
30
+ # 1. Preserve valid format specifiers by replacing them with a placeholder
31
+ placeholders = []
32
+ def save_specifier(m):
33
+ placeholders.append(m.group(0))
34
+ return f"\x00FMTSPEC{len(placeholders) - 1}\x00"
35
+
36
+ text = _VALID_SPECIFIER.sub(save_specifier, text)
37
+
38
+ # 2. Any remaining bare % must be a literal — escape it
39
+ text = text.replace('%', '%%')
40
+
41
+ # 3. Restore the real format specifiers
42
+ for i, spec in enumerate(placeholders):
43
+ text = text.replace(f"\x00FMTSPEC{i}\x00", spec)
44
+
45
+ return text
46
+
47
+
48
+ def _parse_args(args=None):
49
+ parser = argparse.ArgumentParser(description="Fix common string formatting issues in Android strings.xml files.")
50
+ parser.add_argument("--res-dir", default="app/src/main/res", help="Path to the Android res/ directory")
51
+ return parser.parse_args(args)
52
+
53
+
54
+ def main(args=None):
55
+ if args is None or isinstance(args, list):
56
+ args = _parse_args(args)
57
+
58
+ res_dir = args.res_dir
59
+ print(f"Fixing strings in: {res_dir}")
60
+ files = glob.glob(os.path.join(res_dir, "values-*", "strings.xml"))
61
+ fixed_count = 0
62
+
63
+ for xml_file in files:
64
+ with open(xml_file, 'r', encoding='utf-8') as f:
65
+ content = f.read()
66
+
67
+ def fix_match(m):
68
+ return m.group(1) + _fix_text(m.group(2)) + m.group(3)
69
+
70
+ new_content = re.sub(
71
+ r'(<string[^>]*name="[^"]*"[^>]*>)(.*?)(</string>)',
72
+ fix_match, content, flags=re.DOTALL
73
+ )
74
+
75
+ if content != new_content:
76
+ with open(xml_file, 'w', encoding='utf-8') as f:
77
+ f.write(new_content)
78
+ fixed_count += 1
79
+
80
+ print(f"Done fixing strings. Fixed {fixed_count} files out of {len(files)}.")
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
@@ -0,0 +1,148 @@
1
+ import java.io.*;
2
+ import java.util.*;
3
+ import java.util.regex.*;
4
+
5
+ /**
6
+ * Utility to verify that all translated Android strings containing format specifiers
7
+ * (like %1$d or %s) are syntactically valid and won't throw an UnknownFormatConversionException
8
+ * or MissingFormatArgumentException at runtime.
9
+ */
10
+ public class VerifyStrings {
11
+
12
+ public static void main(String[] args) throws Exception {
13
+ String resDirPath = "app/src/main/res";
14
+ if (args.length > 0) {
15
+ resDirPath = args[0];
16
+ }
17
+
18
+ File resDir = new File(resDirPath);
19
+ if (!resDir.exists()) {
20
+ // Try relative to scripts if the absolute/relative failed
21
+ File fallback = new File("../" + resDirPath);
22
+ if (fallback.exists()) {
23
+ resDir = fallback;
24
+ }
25
+ }
26
+
27
+ if (!resDir.exists()) {
28
+ System.err.println("Fatal: Could not find resource directory at: " + resDirPath);
29
+ System.exit(1);
30
+ }
31
+
32
+ File englishDir = new File(resDir, "values");
33
+ File[] valuesDirs = resDir.listFiles((dir, name) -> name.startsWith("values-"));
34
+
35
+ if (valuesDirs == null || valuesDirs.length == 0) {
36
+ System.err.println("No values- localized directories found!");
37
+ return;
38
+ }
39
+
40
+ System.out.println("Started Android String Format Verification...");
41
+ System.out.println("Scanning source strings in: " + englishDir.getPath());
42
+
43
+ // 1. First parse the English strings to know the expected format arguments
44
+ Map<String, String> englishStrings = parseStringsFile(new File(englishDir, "strings.xml"));
45
+ Map<String, List<String>> expectedFormats = new HashMap<>();
46
+
47
+ // Regex for Android / Java format specifiers
48
+ Pattern formatPattern = Pattern.compile("%(\\d+\\$)?([-#+ 0,(<]*)?(\\d+)?(\\.\\d+)?([tT]?[a-zA-Z%])");
49
+ for (Map.Entry<String, String> entry : englishStrings.entrySet()) {
50
+ List<String> formats = new ArrayList<>();
51
+ Matcher m = formatPattern.matcher(entry.getValue());
52
+ while (m.find()) {
53
+ formats.add(m.group());
54
+ }
55
+ expectedFormats.put(entry.getKey(), formats);
56
+ }
57
+
58
+ int totalErrors = 0;
59
+ int totalStringsVerified = 0;
60
+
61
+ // 2. Now verify every language against Android String formatting rules
62
+ for (File dir : valuesDirs) {
63
+ File stringsFile = new File(dir, "strings.xml");
64
+ if (!stringsFile.exists()) continue;
65
+
66
+ Map<String, String> localizedStrings = parseStringsFile(stringsFile);
67
+
68
+ for (Map.Entry<String, String> entry : localizedStrings.entrySet()) {
69
+ String name = entry.getKey();
70
+ String text = entry.getValue();
71
+ totalStringsVerified++;
72
+
73
+ List<String> englishFormats = expectedFormats.get(name);
74
+ if (englishFormats == null || englishFormats.isEmpty()) {
75
+ // This string shouldn't have any format arguments.
76
+ // If it has a %, it must be %%
77
+ if (text.contains("%") && !text.matches("^(?:[^%]|%%)*$")) {
78
+ System.err.println("ERROR: [" + dir.getName() + "] String '" + name + "' contains unescaped % but English doesn't use formatting.");
79
+ System.err.println(" Text: " + text);
80
+ totalErrors++;
81
+ }
82
+ continue;
83
+ }
84
+
85
+ // Try to format it via java natively
86
+ try {
87
+ // Provide generic arguments to satisfy any index like %1$s, %1$d.
88
+ // Our app primarily uses integers and strings up to 3 parameters.
89
+ String.format(text, 100, "test_string", 50);
90
+ } catch (UnknownFormatConversionException e) {
91
+ System.err.println("CRASH: [" + dir.getName() + "] String '" + name + "' has an invalid conversion specifier: " + e.getConversion());
92
+ System.err.println(" Text: " + text);
93
+ totalErrors++;
94
+ } catch (MissingFormatArgumentException e) {
95
+ System.err.println("CRASH: [" + dir.getName() + "] String '" + name + "' is missing format arguments or index is wrong: " + e.getFormatSpecifier());
96
+ System.err.println(" Text: " + text);
97
+ totalErrors++;
98
+ } catch (IllegalFormatConversionException e) {
99
+ System.err.println("CRASH: [" + dir.getName() + "] String '" + name + "' uses wrong variable type logic (e.g. used %d instead of %s): " + e.getMessage());
100
+ System.err.println(" Text: " + text);
101
+ System.err.println(" English expected: " + englishStrings.get(name));
102
+ totalErrors++;
103
+ } catch (Exception e) {
104
+ System.err.println("CRASH: [" + dir.getName() + "] String '" + name + "' failed native String.format() -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
105
+ System.err.println(" Text: " + text);
106
+ totalErrors++;
107
+ }
108
+ }
109
+ }
110
+
111
+ System.out.println("Verification Complete!");
112
+ System.out.println("Total Strings Verified: " + totalStringsVerified);
113
+
114
+ if (totalErrors > 0) {
115
+ System.out.println("Total Errors Found: " + totalErrors);
116
+ System.err.println("Verification Failed!");
117
+ System.exit(1);
118
+ } else {
119
+ System.out.println("Status: ALL STRINGS PASSED!");
120
+ System.exit(0);
121
+ }
122
+ }
123
+
124
+ private static Map<String, String> parseStringsFile(File file) throws Exception {
125
+ Map<String, String> strings = new HashMap<>();
126
+ BufferedReader reader = new BufferedReader(new FileReader(file));
127
+ StringBuilder sb = new StringBuilder();
128
+ String line;
129
+ while ((line = reader.readLine()) != null) {
130
+ sb.append(line).append("\n");
131
+ }
132
+ reader.close();
133
+
134
+ // This regex ensures we only grab actual text contents inside `<string>`
135
+ Pattern stringPattern = Pattern.compile("<string name=\"([^\"]+)\"[^>]*>(.*?)</string>", Pattern.DOTALL);
136
+ Matcher m = stringPattern.matcher(sb.toString());
137
+ while (m.find()) {
138
+ String name = m.group(1);
139
+ String text = m.group(2);
140
+ // Decode standard XML entity things that AAPT2 does automatically
141
+ text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&");
142
+ // Unescape apostrophes and quotes as Java expects clean strings
143
+ text = text.replace("\\'", "'").replace("\\\"", "\"");
144
+ strings.put(name, text);
145
+ }
146
+ return strings;
147
+ }
148
+ }
@@ -0,0 +1,307 @@
1
+ import os
2
+ import time
3
+ import argparse
4
+ import urllib.request
5
+ import urllib.error
6
+ import json
7
+
8
+ DEFAULT_RES_DIR = "app/src/main/res"
9
+ API_TIMEOUT = 60 # seconds
10
+
11
+ # Ordered list of models per provider.
12
+ # First entry = default. Rest = automatic fallbacks (used only when user hasn't pinned a model).
13
+ PROVIDER_MODELS = {
14
+ "gemini": [
15
+ "gemini-2.5-flash",
16
+ "gemini-2.0-flash",
17
+ "gemini-1.5-flash",
18
+ "gemini-1.5-pro",
19
+ ],
20
+ "openai": [
21
+ "gpt-4o-mini",
22
+ "gpt-4o",
23
+ "gpt-3.5-turbo",
24
+ ],
25
+ "anthropic": [
26
+ "claude-3-5-haiku-latest",
27
+ "claude-3-5-sonnet-latest",
28
+ "claude-3-opus-latest",
29
+ ],
30
+ "custom": [], # user must specify --model
31
+ }
32
+
33
+
34
+ def get_target_directories(res_dir):
35
+ """Finds all values-* directories inside the provided res/ directory."""
36
+ dirs = []
37
+ if not os.path.exists(res_dir):
38
+ return dirs
39
+ for d in os.listdir(res_dir):
40
+ if d.startswith("values-") and os.path.isdir(os.path.join(res_dir, d)):
41
+ dirs.append(d)
42
+ return sorted(dirs)
43
+
44
+
45
+ def read_source_xml(source_path):
46
+ with open(source_path, "r", encoding="utf-8") as f:
47
+ return f.read()
48
+
49
+
50
+ def build_prompt(source_xml, target_folder_name, app_context):
51
+ context_str = f"an Android app ({app_context})" if app_context else "an Android application"
52
+ return f"""You are a professional mobile app localization expert.
53
+
54
+ I am sending you the complete English `strings.xml` for {context_str}.
55
+ Your job is to translate it into the language corresponding to the Android resource directory: `{target_folder_name}`.
56
+ For example, `values-hi` is Hindi, `values-es-rES` is Spanish (Spain), `values-zh-rTW` is Traditional Chinese, etc.
57
+
58
+ STRICT GUIDELINES:
59
+ 1. Preserve the exact meaning and intent of the English text.
60
+ 2. The language must be clear, natural, human-sounding, and understandable by all users (from rural to tier-1 cities).
61
+ 3. Do not sound like a machine translation. Use simple, everyday mobile UI language.
62
+ 4. NEVER modify string keys, XML structure, placeholders (like %s, %1$d), escape characters, \\n line breaks, or HTML tags.
63
+ 5. Keep it short and UI friendly.
64
+ 6. Return ONLY the raw updated XML content. Do not add markdown formatting like ```xml or any conversational text.
65
+
66
+ SOURCE XML:
67
+ {source_xml}
68
+ """
69
+
70
+
71
+ def clean_xml_response(result):
72
+ if not result:
73
+ return ""
74
+ result = result.strip()
75
+ if result.startswith("```xml"):
76
+ result = result[6:]
77
+ if result.startswith("```"):
78
+ result = result[3:]
79
+ if result.endswith("```"):
80
+ result = result[:-3]
81
+ return result.strip()
82
+
83
+
84
+ def _read_error_body(e):
85
+ try:
86
+ return e.read().decode("utf-8", errors="replace")[:500]
87
+ except Exception:
88
+ return "(could not read error body)"
89
+
90
+
91
+ def _is_model_not_found(http_code, body):
92
+ """Returns True if the error clearly means the model doesn't exist."""
93
+ if http_code == 404:
94
+ return True
95
+ body_lower = body.lower()
96
+ return any(phrase in body_lower for phrase in [
97
+ "model not found", "model_not_found", "does not exist",
98
+ "no such model", "unknown model", "invalid model",
99
+ ])
100
+
101
+
102
+ def call_gemini(api_key, model, prompt):
103
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
104
+ headers = {"Content-Type": "application/json"}
105
+ data = {"contents": [{"parts": [{"text": prompt}]}]}
106
+ req = urllib.request.Request(url, data=json.dumps(data).encode("utf-8"), headers=headers, method="POST")
107
+ try:
108
+ with urllib.request.urlopen(req, timeout=API_TIMEOUT) as response:
109
+ result = json.loads(response.read().decode("utf-8"))
110
+ candidates = result.get("candidates", [])
111
+ if not candidates:
112
+ print(" ❌ Gemini returned no candidates.")
113
+ return None, False
114
+ return result["candidates"][0].get("content", {}).get("parts", [{}])[0].get("text", ""), False
115
+ except urllib.error.HTTPError as e:
116
+ body = _read_error_body(e)
117
+ model_gone = _is_model_not_found(e.code, body)
118
+ print(f" ❌ Gemini API Error: {e.code} - {body}")
119
+ return None, model_gone
120
+
121
+
122
+ def call_openai_compatible(api_key, base_url, model, prompt):
123
+ headers = {
124
+ "Content-Type": "application/json",
125
+ "Authorization": f"Bearer {api_key or ''}",
126
+ }
127
+ data = {
128
+ "model": model,
129
+ "messages": [{"role": "user", "content": prompt}],
130
+ }
131
+ req = urllib.request.Request(base_url, data=json.dumps(data).encode("utf-8"), headers=headers, method="POST")
132
+ try:
133
+ with urllib.request.urlopen(req, timeout=API_TIMEOUT) as response:
134
+ result = json.loads(response.read().decode("utf-8"))
135
+ choices = result.get("choices", [])
136
+ if not choices:
137
+ print(" ❌ OpenAI returned no choices.")
138
+ return None, False
139
+ return choices[0].get("message", {}).get("content", ""), False
140
+ except urllib.error.HTTPError as e:
141
+ body = _read_error_body(e)
142
+ model_gone = _is_model_not_found(e.code, body)
143
+ print(f" ❌ OpenAI (compatible) API Error: {e.code} - {body}")
144
+ return None, model_gone
145
+
146
+
147
+ def call_anthropic(api_key, model, prompt):
148
+ url = "https://api.anthropic.com/v1/messages"
149
+ headers = {
150
+ "Content-Type": "application/json",
151
+ "x-api-key": api_key,
152
+ "anthropic-version": "2023-06-01",
153
+ }
154
+ data = {
155
+ "model": model,
156
+ "max_tokens": 4096,
157
+ "messages": [{"role": "user", "content": prompt}],
158
+ }
159
+ req = urllib.request.Request(url, data=json.dumps(data).encode("utf-8"), headers=headers, method="POST")
160
+ try:
161
+ with urllib.request.urlopen(req, timeout=API_TIMEOUT) as response:
162
+ result = json.loads(response.read().decode("utf-8"))
163
+ content = result.get("content", [])
164
+ if not content:
165
+ print(" ❌ Anthropic returned no content.")
166
+ return None, False
167
+ return content[0].get("text", ""), False
168
+ except urllib.error.HTTPError as e:
169
+ body = _read_error_body(e)
170
+ model_gone = _is_model_not_found(e.code, body)
171
+ print(f" ❌ Anthropic API Error: {e.code} - {body}")
172
+ return None, model_gone
173
+
174
+
175
+ def _call_provider(provider, api_key, model, prompt, base_url=None):
176
+ """Dispatches to the right API. Returns (text, model_not_found)."""
177
+ if provider == "gemini":
178
+ return call_gemini(api_key, model, prompt)
179
+ elif provider == "openai":
180
+ url = base_url if base_url else "https://api.openai.com/v1/chat/completions"
181
+ return call_openai_compatible(api_key, url, model, prompt)
182
+ elif provider == "anthropic":
183
+ return call_anthropic(api_key, model, prompt)
184
+ else:
185
+ print(f"❌ Unknown provider: {provider}")
186
+ return None, False
187
+
188
+
189
+ def translate_xml(provider, api_key, model, source_xml, target_folder_name, app_context,
190
+ base_url=None, fallback_models=None):
191
+ """
192
+ Calls the selected provider API to translate the XML.
193
+ If the model is not found and fallback_models are provided, retries with the next one.
194
+ Returns (translated_xml, model_used).
195
+ """
196
+ prompt = build_prompt(source_xml, target_folder_name, app_context)
197
+ models_to_try = [model] + (fallback_models or [])
198
+
199
+ for attempt_model in models_to_try:
200
+ if attempt_model != model:
201
+ print(f" ↩️ Falling back to model: {attempt_model}")
202
+ result, model_not_found = _call_provider(provider, api_key, attempt_model, prompt, base_url)
203
+ if result is not None:
204
+ return clean_xml_response(result), attempt_model
205
+ if not model_not_found:
206
+ # Failed for a non-model reason (auth, quota, network) — don't try fallbacks
207
+ return None, attempt_model
208
+
209
+ return None, models_to_try[-1]
210
+
211
+
212
+ def _parse_args(args=None):
213
+ parser = argparse.ArgumentParser(description="Translate Android strings.xml using LLMs.")
214
+ parser.add_argument("--res-dir", default=DEFAULT_RES_DIR)
215
+ parser.add_argument("--provider", choices=["gemini", "openai", "anthropic", "custom"], default="gemini")
216
+ parser.add_argument("--model", help="Any model name for the chosen provider. Uses provider default if not set.")
217
+ parser.add_argument("--api-key")
218
+ parser.add_argument("--base-url")
219
+ parser.add_argument("--app-context")
220
+ parser.add_argument("--sleep", type=float, default=5.0)
221
+ return parser.parse_args(args)
222
+
223
+
224
+ def main(args=None):
225
+ if args is None or isinstance(args, list):
226
+ args = _parse_args(args)
227
+
228
+ provider = args.provider
229
+ user_pinned_model = bool(args.model) # True if user explicitly chose a model
230
+
231
+ # Resolve model + fallback chain
232
+ if args.model:
233
+ # User pinned a specific model — use it, no fallbacks
234
+ model = args.model
235
+ fallback_models = []
236
+ else:
237
+ if provider == "custom":
238
+ print("❌ ERROR: You must specify --model when using a custom provider.")
239
+ return
240
+ model_list = PROVIDER_MODELS.get(provider, [])
241
+ model = model_list[0] if model_list else None
242
+ fallback_models = model_list[1:]
243
+
244
+ # Resolve API key
245
+ api_key = args.api_key
246
+ if not api_key:
247
+ if provider == "gemini": api_key = os.environ.get("GEMINI_API_KEY")
248
+ elif provider in ("openai", "custom"): api_key = os.environ.get("OPENAI_API_KEY")
249
+ elif provider == "anthropic": api_key = os.environ.get("ANTHROPIC_API_KEY")
250
+ if not api_key:
251
+ api_key = os.environ.get("API_KEY")
252
+
253
+ if not api_key and provider != "custom":
254
+ print("❌ ERROR: Please provide an API key via --api-key or the appropriate environment variable.")
255
+ return
256
+
257
+ if provider == "custom" and not args.base_url:
258
+ print("❌ ERROR: You must provide --base-url when using a custom provider.")
259
+ return
260
+
261
+ res_dir = args.res_dir
262
+ source_strings_xml = os.path.join(res_dir, "values", "strings.xml")
263
+
264
+ print(f"🔍 Reading source XML from: {source_strings_xml}")
265
+ if not os.path.exists(source_strings_xml):
266
+ print("❌ ERROR: Could not find English strings.xml at the specified path.")
267
+ return
268
+
269
+ source_xml = read_source_xml(source_strings_xml)
270
+ target_dirs = get_target_directories(res_dir)
271
+
272
+ if not target_dirs:
273
+ print(f"⚠️ No values-* localized directories found in {res_dir}")
274
+ return
275
+
276
+ print(f"🌍 Found {len(target_dirs)} language directories.")
277
+ fallback_note = "" if user_pinned_model else f" (fallbacks: {', '.join(fallback_models)})" if fallback_models else ""
278
+ print(f"🤖 Provider: {provider.upper()} | Model: {model}{fallback_note}")
279
+
280
+ actual_provider = "openai" if provider == "custom" else provider
281
+
282
+ for folder in target_dirs:
283
+ target_path = os.path.join(res_dir, folder, "strings.xml")
284
+ print(f"⏳ Translating for {folder}...")
285
+
286
+ translated_xml, used_model = translate_xml(
287
+ actual_provider, api_key, model, source_xml,
288
+ folder, args.app_context, args.base_url, fallback_models
289
+ )
290
+
291
+ if translated_xml and "<resources>" in translated_xml and "</resources>" in translated_xml:
292
+ os.makedirs(os.path.dirname(target_path), exist_ok=True)
293
+ with open(target_path, "w", encoding="utf-8") as f:
294
+ f.write(translated_xml)
295
+ suffix = f" (via {used_model})" if used_model != model else ""
296
+ print(f"✅ Saved translated strings.xml to {folder}{suffix}")
297
+ else:
298
+ print(f"⚠️ Failed or got invalid XML for {folder}. Skipping.")
299
+
300
+ if args.sleep > 0:
301
+ time.sleep(args.sleep)
302
+
303
+ print("\n🎉 Translation process completed!")
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()
@@ -0,0 +1,56 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import argparse
5
+
6
+
7
+ def _parse_args(args=None):
8
+ parser = argparse.ArgumentParser(description="Verify Android strings formatting.")
9
+ parser.add_argument("--res-dir", default="app/src/main/res", help="Path to the Android res/ directory")
10
+ return parser.parse_args(args)
11
+
12
+
13
+ def main(args=None):
14
+ if args is None or isinstance(args, list):
15
+ args = _parse_args(args)
16
+
17
+ package_dir = os.path.dirname(os.path.abspath(__file__))
18
+ java_file = os.path.join(package_dir, "java", "VerifyStrings.java")
19
+ java_out_dir = os.path.join(package_dir, "java")
20
+ project_root = os.getcwd()
21
+
22
+ if not os.path.exists(java_file):
23
+ print(f"[!] ERROR: Java verifier not found at {java_file}")
24
+ print(" This may indicate a broken installation. Try:")
25
+ print(" pip install --force-reinstall android-localisation")
26
+ sys.exit(1)
27
+
28
+ # 1. Compile the Java verifier
29
+ print("Compiling VerifyStrings.java...")
30
+ try:
31
+ subprocess.run(["javac", "-d", java_out_dir, java_file], check=True)
32
+ except FileNotFoundError:
33
+ print("\n[!] ERROR: 'javac' command not found.")
34
+ print(" Please ensure you have a Java JDK installed and 'javac' is in your system PATH.")
35
+ print(" Alternatively, run this from the Terminal inside Android Studio.")
36
+ sys.exit(1)
37
+ except subprocess.CalledProcessError:
38
+ print("Failed to compile VerifyStrings.java")
39
+ sys.exit(1)
40
+
41
+ # 2. Run the Java verifier
42
+ print(f"Running String Verifier against {args.res_dir}...")
43
+ run_result = subprocess.run(
44
+ ["java", "-cp", java_out_dir, "VerifyStrings", args.res_dir],
45
+ cwd=project_root
46
+ )
47
+
48
+ if run_result.returncode != 0:
49
+ print("\n[!] VERIFICATION FAILED: Found broken string formatting that could crash the app.")
50
+ sys.exit(run_result.returncode)
51
+ else:
52
+ print("\n[+] VERIFICATION PASSED: All localizations are syntactically safe.")
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: android-localisation
3
+ Version: 1.0.0
4
+ Summary: Zero-dependency Android strings.xml translation and verification using LLMs (Gemini, OpenAI, Anthropic, Ollama).
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/BharathKmalviya/android-llm-localization
7
+ Project-URL: Repository, https://github.com/BharathKmalviya/android-llm-localization
8
+ Project-URL: Issues, https://github.com/BharathKmalviya/android-llm-localization/issues
9
+ Project-URL: Changelog, https://github.com/BharathKmalviya/android-llm-localization/blob/master/CHANGELOG.md
10
+ Keywords: android,localisation,localization,strings,translation,llm,gemini,openai,anthropic,ollama,i18n,l10n
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Internationalization
23
+ Classifier: Topic :: Software Development :: Localization
24
+ Requires-Python: >=3.8
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # Android LLM Localization
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/android-localisation.svg)](https://pypi.org/project/android-localisation/)
32
+ [![Python 3.8+](https://img.shields.io/pypi/pyversions/android-localisation.svg)](https://pypi.org/project/android-localisation/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BharathKmalviya/android-llm-localization/blob/master/LICENSE)
34
+
35
+ A zero-dependency Python tool to translate, fix, and verify Android `strings.xml` resources using Large Language Models (LLMs) — Gemini, OpenAI, Anthropic (Claude), or any local model via Ollama.
36
+
37
+ ## Why this exists?
38
+
39
+ Localizing Android apps usually involves paying for services, exporting CSVs, or manually using Google Translate. This tool translates `strings.xml` directly in your project using modern LLMs, giving significantly better context-aware translations.
40
+
41
+ **Zero dependencies** — no `pip install` of extra libraries required. Uses only Python's built-in networking.
42
+
43
+ ---
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install android-localisation
49
+ ```
50
+
51
+ To update to the latest version:
52
+
53
+ ```bash
54
+ pip install --upgrade android-localisation
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Quick Start
60
+
61
+ ```bash
62
+ # 1. Translate (Gemini free tier recommended)
63
+ android-localise translate --api-key YOUR_GEMINI_API_KEY
64
+
65
+ # 2. Fix any XML escaping issues introduced by the LLM
66
+ android-localise fix
67
+
68
+ # 3. Verify no format specifiers were corrupted (requires Java JDK)
69
+ android-localise verify
70
+ ```
71
+
72
+ ---
73
+
74
+ ## The Three Commands
75
+
76
+ ### 1. `translate` — Translate strings into all locale directories
77
+
78
+ Reads `app/src/main/res/values/strings.xml` (English) and writes translated `strings.xml` into every `values-*` directory it finds.
79
+
80
+ **Prerequisites:**
81
+ - Create empty `values-<lang>/` folders for each language you want (e.g. `values-hi/`, `values-es/`)
82
+ - Get an API key — **[Google Gemini AI Studio](https://aistudio.google.com/) has a generous free tier**
83
+
84
+ ```bash
85
+ # Basic — Gemini (default)
86
+ android-localise translate --api-key YOUR_GEMINI_API_KEY
87
+
88
+ # With app context for better translations
89
+ android-localise translate --api-key YOUR_KEY --app-context "a fitness tracking app"
90
+
91
+ # OpenAI
92
+ android-localise translate --api-key YOUR_KEY --provider openai --model gpt-4o
93
+
94
+ # Anthropic (Claude)
95
+ android-localise translate --api-key YOUR_KEY --provider anthropic
96
+
97
+ # Local model via Ollama
98
+ android-localise translate --provider custom --base-url http://localhost:11434/v1/chat/completions --model llama3
99
+ ```
100
+
101
+ **Full arguments:**
102
+
103
+ | Argument | Description | Default |
104
+ |---|---|---|
105
+ | `--api-key` | API key (or set `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` env vars) | *None* |
106
+ | `--provider` | AI provider: `gemini`, `openai`, `anthropic`, `custom` | `gemini` |
107
+ | `--model` | Model name (e.g. `gemini-2.5-flash`, `gpt-4o`, `claude-3-5-sonnet-latest`) | provider default |
108
+ | `--app-context` | Short description of your app to improve translation quality | *None* |
109
+ | `--res-dir` | Path to the Android `res/` directory | `app/src/main/res` |
110
+ | `--base-url` | Base URL for custom OpenAI-compatible endpoints | *None* |
111
+ | `--sleep` | Seconds between requests to avoid rate limits | `5.0` |
112
+
113
+ ---
114
+
115
+ ### 2. `fix` — Fix XML escaping issues
116
+
117
+ LLMs occasionally introduce malformed characters — curly apostrophes (`'`), unescaped quotes, or broken `%` symbols. This command cleans them all up.
118
+
119
+ ```bash
120
+ android-localise fix
121
+ # or with a custom res dir:
122
+ android-localise fix --res-dir path/to/res
123
+ ```
124
+
125
+ ---
126
+
127
+ ### 3. `verify` — Catch format specifier crashes before they happen
128
+
129
+ LLMs can corrupt Android format specifiers like `%1$s` or `%d`, which causes `UnknownFormatConversionException` crashes at runtime. This command compiles a Java verifier and dry-runs `String.format()` against every translated string.
130
+
131
+ ```bash
132
+ android-localise verify
133
+ ```
134
+
135
+ *Requires `javac` in your system PATH. Run from Android Studio's terminal if needed.*
136
+
137
+ ---
138
+
139
+ ## Recommended Workflow
140
+
141
+ 1. Update your English `strings.xml`
142
+ 2. Create empty `values-<lang>/` folders for the languages you want
143
+ 3. `android-localise translate --api-key YOUR_KEY`
144
+ 4. `android-localise fix`
145
+ 5. `android-localise verify`
146
+ 6. Build and test your app
147
+
148
+ ---
149
+
150
+ ## Supported Providers
151
+
152
+ | Provider | Default Model | API Key Env Var |
153
+ |---|---|---|
154
+ | `gemini` (default) | `gemini-2.5-flash` | `GEMINI_API_KEY` |
155
+ | `openai` | `gpt-4o-mini` | `OPENAI_API_KEY` |
156
+ | `anthropic` | `claude-3-5-sonnet-latest` | `ANTHROPIC_API_KEY` |
157
+ | `custom` | *(must specify)* | `OPENAI_API_KEY` or none |
158
+
159
+ ---
160
+
161
+ ---
162
+
163
+ ## Contributing
164
+
165
+ Issues and PRs welcome at [github.com/BharathKmalviya/android-llm-localization](https://github.com/BharathKmalviya/android-llm-localization).
166
+
167
+ ---
168
+
169
+ *Created to make Android localization accessible, free, and completely automated.*
@@ -0,0 +1,12 @@
1
+ android_localisation/__init__.py,sha256=QrUJ1_CLiP98-QELk2bzsjKAmDUhwyrLY6ouN2aBjzU,113
2
+ android_localisation/cli.py,sha256=S1xLogHM7ML5rTJXifFXk_zVkoCKV8_VyVN9FVP2L8s,3857
3
+ android_localisation/fix.py,sha256=QpN_4YLSuiB9KzDcCv5OiT5-7vwlrDw33WRq8j50HKA,2602
4
+ android_localisation/translate.py,sha256=C8uEhNACpgntbpM6HrqNtqycMfnU0oa6UwLox4t4Duw,11838
5
+ android_localisation/verify.py,sha256=gufTTz-ZPUarI9ZWKrsb587rH_VzWFZBjgScaUKpick,2024
6
+ android_localisation/java/VerifyStrings.java,sha256=UeC-jmi8lYFz57-lpcLdZhozYONwARNA1gteFab_brg,6897
7
+ android_localisation-1.0.0.dist-info/licenses/LICENSE,sha256=hMf4K6lMGkL8Fs8O0DTy1fShCwbVk63hRcOqrbKRRE0,1090
8
+ android_localisation-1.0.0.dist-info/METADATA,sha256=b5fMj6-YRsK3IuQFC511ZpL3AAvTeiHumrWeeDfHFOA,6366
9
+ android_localisation-1.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
10
+ android_localisation-1.0.0.dist-info/entry_points.txt,sha256=NN78HpRqDjb95Pl1ynVeU76DjQXHJXtCLgp8L-SVGO8,67
11
+ android_localisation-1.0.0.dist-info/top_level.txt,sha256=ImH9pjj_nTP7r6SmTAZOsI36ikz4-LM9Yi23T_9YcFs,21
12
+ android_localisation-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ android-localise = android_localisation.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 android-localisation contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ android_localisation