663 lines
21 KiB
Python
Executable File
663 lines
21 KiB
Python
Executable File
#!/usr/bin/env vpython3
|
|
# encoding: utf-8
|
|
# Copyright 2019 The Chromium Authors
|
|
# Use of this source code is governed by a BSD-style license that can be
|
|
# found in the LICENSE file.
|
|
|
|
"""A script to parse and dump localized strings in resource.arsc files."""
|
|
|
|
|
|
import argparse
|
|
import collections
|
|
import contextlib
|
|
import cProfile
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
# pylint: disable=bare-except
|
|
|
|
# Assuming this script is located under build/android, try to import
|
|
# build/android/gyp/bundletool.py to get the default path to the bundletool
|
|
# jar file. If this fail, using --bundletool-path will be required to parse
|
|
# bundles, allowing this script to be relocated or reused somewhere else.
|
|
try:
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'gyp'))
|
|
import bundletool
|
|
|
|
_DEFAULT_BUNDLETOOL_PATH = bundletool.BUNDLETOOL_JAR_PATH
|
|
except:
|
|
_DEFAULT_BUNDLETOOL_PATH = None
|
|
|
|
# Try to get the path of the aapt build tool from catapult/devil.
|
|
try:
|
|
import devil_chromium # pylint: disable=unused-import
|
|
from devil.android.sdk import build_tools
|
|
_AAPT_DEFAULT_PATH = build_tools.GetPath('aapt')
|
|
except:
|
|
_AAPT_DEFAULT_PATH = None
|
|
|
|
|
|
def AutoIndentStringList(lines, indentation=2):
|
|
"""Auto-indents a input list of text lines, based on open/closed braces.
|
|
|
|
For example, the following input text:
|
|
|
|
'Foo {',
|
|
'Bar {',
|
|
'Zoo',
|
|
'}',
|
|
'}',
|
|
|
|
Will return the following:
|
|
|
|
'Foo {',
|
|
' Bar {',
|
|
' Zoo',
|
|
' }',
|
|
'}',
|
|
|
|
The rules are pretty simple:
|
|
- A line that ends with an open brace ({) increments indentation.
|
|
- A line that starts with a closing brace (}) decrements it.
|
|
|
|
The main idea is to make outputting structured text data trivial,
|
|
since it can be assumed that the final output will be passed through
|
|
this function to make it human-readable.
|
|
|
|
Args:
|
|
lines: an iterator over input text lines. They should not contain
|
|
line terminator (e.g. '\n').
|
|
Returns:
|
|
A new list of text lines, properly auto-indented.
|
|
"""
|
|
margin = ''
|
|
result = []
|
|
# NOTE: Intentional but significant speed optimizations in this function:
|
|
# - |line and line[0] == <char>| instead of |line.startswith(<char>)|.
|
|
# - |line and line[-1] == <char>| instead of |line.endswith(<char>)|.
|
|
for line in lines:
|
|
if line and line[0] == '}':
|
|
margin = margin[:-indentation]
|
|
result.append(margin + line)
|
|
if line and line[-1] == '{':
|
|
margin += ' ' * indentation
|
|
|
|
return result
|
|
|
|
|
|
# pylint: disable=line-too-long
|
|
|
|
# NOTE: aapt dump will quote the following characters only: \n, \ and "
|
|
# see https://cs.android.com/search?q=f:ResourceTypes.cpp
|
|
|
|
# pylint: enable=line-too-long
|
|
|
|
|
|
def UnquoteString(s):
|
|
"""Unquote a given string from aapt dump.
|
|
|
|
Args:
|
|
s: An UTF-8 encoded string that contains backslashes for quotes, as found
|
|
in the output of 'aapt dump resources --values'.
|
|
Returns:
|
|
The unquoted version of the input string.
|
|
"""
|
|
if not '\\' in s:
|
|
return s
|
|
|
|
result = ''
|
|
start = 0
|
|
size = len(s)
|
|
while start < size:
|
|
pos = s.find('\\', start)
|
|
if pos < 0:
|
|
break
|
|
|
|
result += s[start:pos]
|
|
count = 1
|
|
while pos + count < size and s[pos + count] == '\\':
|
|
count += 1
|
|
|
|
result += '\\' * (count // 2)
|
|
start = pos + count
|
|
if count & 1:
|
|
if start < size:
|
|
ch = s[start]
|
|
if ch == 'n': # \n is the only non-printable character supported.
|
|
ch = '\n'
|
|
result += ch
|
|
start += 1
|
|
else:
|
|
result += '\\'
|
|
|
|
result += s[start:]
|
|
return result
|
|
|
|
|
|
assert UnquoteString(r'foo bar') == 'foo bar'
|
|
assert UnquoteString(r'foo\nbar') == 'foo\nbar'
|
|
assert UnquoteString(r'foo\\nbar') == 'foo\\nbar'
|
|
assert UnquoteString(r'foo\\\nbar') == 'foo\\\nbar'
|
|
assert UnquoteString(r'foo\n\nbar') == 'foo\n\nbar'
|
|
assert UnquoteString(r'foo\\bar') == r'foo\bar'
|
|
|
|
|
|
def QuoteString(s):
|
|
"""Quote a given string for external output.
|
|
|
|
Args:
|
|
s: An input UTF-8 encoded string.
|
|
Returns:
|
|
A quoted version of the string, using the same rules as 'aapt dump'.
|
|
"""
|
|
# NOTE: Using repr() would escape all non-ASCII bytes in the string, which
|
|
# is undesirable.
|
|
return s.replace('\\', r'\\').replace('"', '\\"').replace('\n', '\\n')
|
|
|
|
|
|
assert QuoteString(r'foo "bar"') == 'foo \\"bar\\"'
|
|
assert QuoteString('foo\nbar') == 'foo\\nbar'
|
|
|
|
|
|
def ReadStringMapFromRTxt(r_txt_path):
|
|
"""Read all string resource IDs and names from an R.txt file.
|
|
|
|
Args:
|
|
r_txt_path: Input file path.
|
|
Returns:
|
|
A {res_id -> res_name} dictionary corresponding to the string resources
|
|
from the input R.txt file.
|
|
"""
|
|
# NOTE: Typical line of interest looks like:
|
|
# int string AllowedDomainsForAppsTitle 0x7f130001
|
|
result = {}
|
|
prefix = 'int string '
|
|
with open(r_txt_path) as f:
|
|
for line in f:
|
|
line = line.rstrip()
|
|
if line.startswith(prefix):
|
|
res_name, res_id = line[len(prefix):].split(' ')
|
|
result[int(res_id, 0)] = res_name
|
|
return result
|
|
|
|
|
|
class ResourceStringValues:
|
|
"""Models all possible values for a named string."""
|
|
|
|
def __init__(self):
|
|
self.res_name = None
|
|
self.res_values = {}
|
|
|
|
def AddValue(self, res_name, res_config, res_value):
|
|
"""Add a new value to this entry.
|
|
|
|
Args:
|
|
res_name: Resource name. If this is not the first time this method
|
|
is called with the same resource name, then |res_name| should match
|
|
previous parameters for sanity checking.
|
|
res_config: Config associated with this value. This can actually be
|
|
anything that can be converted to a string.
|
|
res_value: UTF-8 encoded string value.
|
|
"""
|
|
if res_name is not self.res_name and res_name != self.res_name:
|
|
if self.res_name is None:
|
|
self.res_name = res_name
|
|
else:
|
|
# Sanity check: the resource name should be the same for all chunks.
|
|
# Resource ID is redefined with a different name!!
|
|
print('WARNING: Resource key ignored (%s, should be %s)' %
|
|
(res_name, self.res_name))
|
|
|
|
if self.res_values.setdefault(res_config, res_value) is not res_value:
|
|
print('WARNING: Duplicate value definition for [config %s]: %s ' \
|
|
'(already has %s)' % (
|
|
res_config, res_value, self.res_values[res_config]))
|
|
|
|
def ToStringList(self, res_id):
|
|
"""Convert entry to string list for human-friendly output."""
|
|
values = sorted([(str(config), value)
|
|
for config, value in self.res_values.items()])
|
|
if res_id is None:
|
|
# res_id will be None when the resource ID should not be part
|
|
# of the output.
|
|
result = ['name=%s count=%d {' % (self.res_name, len(values))]
|
|
else:
|
|
result = [
|
|
'res_id=0x%08x name=%s count=%d {' % (res_id, self.res_name,
|
|
len(values))
|
|
]
|
|
for config, value in values:
|
|
result.append('%-16s "%s"' % (config, QuoteString(value)))
|
|
result.append('}')
|
|
return result
|
|
|
|
|
|
class ResourceStringMap:
|
|
"""Convenience class to hold the set of all localized strings in a table.
|
|
|
|
Usage is the following:
|
|
1) Create new (empty) instance.
|
|
2) Call AddValue() repeatedly to add new values.
|
|
3) Eventually call RemapResourceNames() to remap resource names.
|
|
4) Call ToStringList() to convert the instance to a human-readable
|
|
list of strings that can later be used with AutoIndentStringList()
|
|
for example.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._res_map = collections.defaultdict(ResourceStringValues)
|
|
|
|
def AddValue(self, res_id, res_name, res_config, res_value):
|
|
self._res_map[res_id].AddValue(res_name, res_config, res_value)
|
|
|
|
def RemapResourceNames(self, id_name_map):
|
|
"""Rename all entries according to a given {res_id -> res_name} map."""
|
|
for res_id, res_name in id_name_map.items():
|
|
if res_id in self._res_map:
|
|
self._res_map[res_id].res_name = res_name
|
|
|
|
def ToStringList(self, omit_ids=False):
|
|
"""Dump content to a human-readable string list.
|
|
|
|
Note that the strings are ordered by their resource name first, and
|
|
resource id second.
|
|
|
|
Args:
|
|
omit_ids: If True, do not put resource IDs in the result. This might
|
|
be useful when comparing the outputs of two different builds of the
|
|
same APK, or two related APKs (e.g. ChromePublic.apk vs Chrome.apk)
|
|
where the resource IDs might be slightly different, but not the
|
|
string contents.
|
|
Return:
|
|
A list of strings that can later be sent to AutoIndentStringList().
|
|
"""
|
|
result = ['Resource strings (count=%d) {' % len(self._res_map)]
|
|
res_map = self._res_map
|
|
|
|
# Compare two (res_id, values) tuples by resource name first, then resource
|
|
# ID.
|
|
for res_id, _ in sorted(res_map.items(),
|
|
key=lambda x: (x[1].res_name, x[0])):
|
|
result += res_map[res_id].ToStringList(None if omit_ids else res_id)
|
|
result.append('} # Resource strings')
|
|
return result
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def ManagedOutput(output_file):
|
|
"""Create an output File object that will be closed on exit if necessary.
|
|
|
|
Args:
|
|
output_file: Optional output file path.
|
|
Yields:
|
|
If |output_file| is empty, this simply yields sys.stdout. Otherwise, this
|
|
opens the file path for writing text, and yields its File object. The
|
|
context will ensure that the object is always closed on scope exit.
|
|
"""
|
|
close_output = False
|
|
if output_file:
|
|
output = open(output_file, 'wt')
|
|
close_output = True
|
|
else:
|
|
output = sys.stdout
|
|
try:
|
|
yield output
|
|
finally:
|
|
if close_output:
|
|
output.close()
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def ManagedPythonProfiling(enable_profiling, sort_key='tottime'):
|
|
"""Enable Python profiling if needed.
|
|
|
|
Args:
|
|
enable_profiling: Boolean flag. True to enable python profiling.
|
|
sort_key: Sorting key for the final stats dump.
|
|
Yields:
|
|
If |enable_profiling| is False, this yields False. Otherwise, this
|
|
yields a new Profile instance just after enabling it. The manager
|
|
ensures that profiling stops and prints statistics on scope exit.
|
|
"""
|
|
pr = None
|
|
if enable_profiling:
|
|
pr = cProfile.Profile()
|
|
pr.enable()
|
|
try:
|
|
yield pr
|
|
finally:
|
|
if pr:
|
|
pr.disable()
|
|
pr.print_stats(sort=sort_key)
|
|
|
|
|
|
def IsFilePathABundle(input_file):
|
|
"""Return True iff |input_file| holds an Android app bundle."""
|
|
try:
|
|
with zipfile.ZipFile(input_file) as input_zip:
|
|
_ = input_zip.getinfo('BundleConfig.pb')
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
|
|
# Example output from 'bundletool dump resources --values' corresponding
|
|
# to strings:
|
|
#
|
|
# 0x7F1200A0 - string/abc_action_menu_overflow_description
|
|
# (default) - [STR] "More options"
|
|
# locale: "ca" - [STR] "Més opcions"
|
|
# locale: "da" - [STR] "Flere muligheder"
|
|
# locale: "fa" - [STR] " گزینه<U+200C>های بیشتر"
|
|
# locale: "ja" - [STR] "その他のオプション"
|
|
# locale: "ta" - [STR] "மேலும் விருப்பங்கள்"
|
|
# locale: "nb" - [STR] "Flere alternativer"
|
|
# ...
|
|
#
|
|
# Fun fact #1: Bundletool uses <lang>-<REGION> instead of <lang>-r<REGION>
|
|
# for locales!
|
|
#
|
|
# Fun fact #2: The <U+200C> is terminal output for \u200c, the output is
|
|
# really UTF-8 encoded when it is read by this script.
|
|
#
|
|
# Fun fact #3: Bundletool quotes \n, \\ and \" just like aapt since 0.8.0.
|
|
#
|
|
_RE_BUNDLE_STRING_RESOURCE_HEADER = re.compile(
|
|
r'^0x([0-9A-F]+)\s\-\sstring/(\w+)$')
|
|
assert _RE_BUNDLE_STRING_RESOURCE_HEADER.match(
|
|
'0x7F1200A0 - string/abc_action_menu_overflow_description')
|
|
|
|
_RE_BUNDLE_STRING_DEFAULT_VALUE = re.compile(
|
|
r'^\s+\(default\) - \[STR\] "(.*)"$')
|
|
assert _RE_BUNDLE_STRING_DEFAULT_VALUE.match(
|
|
' (default) - [STR] "More options"')
|
|
assert _RE_BUNDLE_STRING_DEFAULT_VALUE.match(
|
|
' (default) - [STR] "More options"').group(1) == "More options"
|
|
|
|
_RE_BUNDLE_STRING_LOCALIZED_VALUE = re.compile(
|
|
r'^\s+locale: "([0-9a-zA-Z-]+)" - \[STR\] "(.*)"$')
|
|
assert _RE_BUNDLE_STRING_LOCALIZED_VALUE.match(
|
|
' locale: "ar" - [STR] "گزینه\u200cهای بیشتر"')
|
|
|
|
|
|
def ParseBundleResources(bundle_tool_jar_path, bundle_path):
|
|
"""Use bundletool to extract the localized strings of a given bundle.
|
|
|
|
Args:
|
|
bundle_tool_jar_path: Path to bundletool .jar executable.
|
|
bundle_path: Path to input bundle.
|
|
Returns:
|
|
A new ResourceStringMap instance populated with the bundle's content.
|
|
"""
|
|
cmd_args = [
|
|
'java', '-jar', bundle_tool_jar_path, 'dump', 'resources', '--bundle',
|
|
bundle_path, '--values'
|
|
]
|
|
p = subprocess.Popen(cmd_args, bufsize=1, stdout=subprocess.PIPE)
|
|
res_map = ResourceStringMap()
|
|
current_resource_id = None
|
|
current_resource_name = None
|
|
keep_parsing = True
|
|
need_value = False
|
|
while keep_parsing:
|
|
line = p.stdout.readline()
|
|
if not line:
|
|
break
|
|
# Do not use rstrip(), since this should only remove trailing newlines
|
|
# but not trailing whitespace that happen to be embedded in the string
|
|
# value for some reason.
|
|
line = line.rstrip('\n\r')
|
|
m = _RE_BUNDLE_STRING_RESOURCE_HEADER.match(line)
|
|
if m:
|
|
current_resource_id = int(m.group(1), 16)
|
|
current_resource_name = m.group(2)
|
|
need_value = True
|
|
continue
|
|
|
|
if not need_value:
|
|
continue
|
|
|
|
resource_config = None
|
|
m = _RE_BUNDLE_STRING_DEFAULT_VALUE.match(line)
|
|
if m:
|
|
resource_config = 'config (default)'
|
|
resource_value = m.group(1)
|
|
else:
|
|
m = _RE_BUNDLE_STRING_LOCALIZED_VALUE.match(line)
|
|
if m:
|
|
resource_config = 'config %s' % m.group(1)
|
|
resource_value = m.group(2)
|
|
|
|
if resource_config is None:
|
|
need_value = False
|
|
continue
|
|
|
|
res_map.AddValue(current_resource_id, current_resource_name,
|
|
resource_config, UnquoteString(resource_value))
|
|
return res_map
|
|
|
|
|
|
# Name of the binary resources table file inside an APK.
|
|
RESOURCES_FILENAME = 'resources.arsc'
|
|
|
|
|
|
def IsFilePathAnApk(input_file):
|
|
"""Returns True iff a ZipFile instance is for a regular APK."""
|
|
try:
|
|
with zipfile.ZipFile(input_file) as input_zip:
|
|
_ = input_zip.getinfo(RESOURCES_FILENAME)
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
|
|
# pylint: disable=line-too-long
|
|
|
|
# Example output from 'aapt dump resources --values' corresponding
|
|
# to strings:
|
|
#
|
|
# config zh-rHK
|
|
# resource 0x7f12009c org.chromium.chrome:string/0_resource_name_obfuscated: t=0x03 d=0x0000caa9 (s=0x0008 r=0x00)
|
|
# (string8) "瀏覽首頁"
|
|
# resource 0x7f12009d org.chromium.chrome:string/0_resource_name_obfuscated: t=0x03 d=0x0000c8e0 (s=0x0008 r=0x00)
|
|
# (string8) "向上瀏覽"
|
|
#
|
|
|
|
# The following are compiled regular expressions used to recognize each
|
|
# of line and extract relevant information.
|
|
#
|
|
_RE_AAPT_CONFIG = re.compile(r'^\s+config (.+):$')
|
|
assert _RE_AAPT_CONFIG.match(' config (default):')
|
|
assert _RE_AAPT_CONFIG.match(' config zh-rTW:')
|
|
|
|
# Match an ISO 639-1 or ISO 639-2 locale.
|
|
_RE_AAPT_ISO_639_LOCALE = re.compile(r'^[a-z]{2,3}(-r[A-Z]{2,3})?$')
|
|
assert _RE_AAPT_ISO_639_LOCALE.match('de')
|
|
assert _RE_AAPT_ISO_639_LOCALE.match('zh-rTW')
|
|
assert _RE_AAPT_ISO_639_LOCALE.match('fil')
|
|
assert not _RE_AAPT_ISO_639_LOCALE.match('land')
|
|
|
|
_RE_AAPT_BCP47_LOCALE = re.compile(r'^b\+[a-z][a-zA-Z0-9\+]+$')
|
|
assert _RE_AAPT_BCP47_LOCALE.match('b+sr')
|
|
assert _RE_AAPT_BCP47_LOCALE.match('b+sr+Latn')
|
|
assert _RE_AAPT_BCP47_LOCALE.match('b+en+US')
|
|
assert not _RE_AAPT_BCP47_LOCALE.match('b+')
|
|
assert not _RE_AAPT_BCP47_LOCALE.match('b+1234')
|
|
|
|
_RE_AAPT_STRING_RESOURCE_HEADER = re.compile(
|
|
r'^\s+resource 0x([0-9a-f]+) [a-zA-Z][a-zA-Z0-9.]+:string/(\w+):.*$')
|
|
assert _RE_AAPT_STRING_RESOURCE_HEADER.match(
|
|
r' resource 0x7f12009c org.chromium.chrome:string/0_resource_name_obfuscated: t=0x03 d=0x0000caa9 (s=0x0008 r=0x00)'
|
|
)
|
|
|
|
_RE_AAPT_STRING_RESOURCE_VALUE = re.compile(r'^\s+\(string8\) "(.*)"$')
|
|
assert _RE_AAPT_STRING_RESOURCE_VALUE.match(r' (string8) "瀏覽首頁"')
|
|
|
|
# pylint: enable=line-too-long
|
|
|
|
|
|
def _ConvertAaptLocaleToBcp47(locale):
|
|
"""Convert a locale name from 'aapt dump' to its BCP-47 form."""
|
|
if locale.startswith('b+'):
|
|
return '-'.join(locale[2:].split('+'))
|
|
lang, _, region = locale.partition('-r')
|
|
if region:
|
|
return '%s-%s' % (lang, region)
|
|
return lang
|
|
|
|
|
|
assert _ConvertAaptLocaleToBcp47('(default)') == '(default)'
|
|
assert _ConvertAaptLocaleToBcp47('en') == 'en'
|
|
assert _ConvertAaptLocaleToBcp47('en-rUS') == 'en-US'
|
|
assert _ConvertAaptLocaleToBcp47('en-US') == 'en-US'
|
|
assert _ConvertAaptLocaleToBcp47('fil') == 'fil'
|
|
assert _ConvertAaptLocaleToBcp47('b+sr+Latn') == 'sr-Latn'
|
|
|
|
|
|
def ParseApkResources(aapt_path, apk_path):
|
|
"""Use aapt to extract the localized strings of a given bundle.
|
|
|
|
Args:
|
|
bundle_tool_jar_path: Path to bundletool .jar executable.
|
|
bundle_path: Path to input bundle.
|
|
Returns:
|
|
A new ResourceStringMap instance populated with the bundle's content.
|
|
"""
|
|
cmd_args = [aapt_path, 'dump', '--values', 'resources', apk_path]
|
|
p = subprocess.Popen(cmd_args, bufsize=1, stdout=subprocess.PIPE)
|
|
|
|
res_map = ResourceStringMap()
|
|
current_locale = None
|
|
current_resource_id = -1 # represents undefined.
|
|
current_resource_name = None
|
|
need_value = False
|
|
while True:
|
|
try:
|
|
line = p.stdout.readline().rstrip().decode('utf8')
|
|
except UnicodeDecodeError:
|
|
continue
|
|
|
|
if not line:
|
|
break
|
|
m = _RE_AAPT_CONFIG.match(line)
|
|
if m:
|
|
locale = None
|
|
aapt_locale = m.group(1)
|
|
if aapt_locale == '(default)':
|
|
locale = aapt_locale
|
|
elif _RE_AAPT_ISO_639_LOCALE.match(aapt_locale):
|
|
locale = aapt_locale
|
|
elif _RE_AAPT_BCP47_LOCALE.match(aapt_locale):
|
|
locale = aapt_locale
|
|
if locale is not None:
|
|
current_locale = _ConvertAaptLocaleToBcp47(locale)
|
|
continue
|
|
|
|
if current_locale is None:
|
|
continue
|
|
|
|
if need_value:
|
|
m = _RE_AAPT_STRING_RESOURCE_VALUE.match(line)
|
|
if not m:
|
|
# Should not happen
|
|
sys.stderr.write('WARNING: Missing value for string ID 0x%08x "%s"' %
|
|
(current_resource_id, current_resource_name))
|
|
resource_value = '<MISSING_STRING_%08x>' % current_resource_id
|
|
else:
|
|
resource_value = UnquoteString(m.group(1))
|
|
|
|
res_map.AddValue(current_resource_id, current_resource_name,
|
|
'config %s' % current_locale, resource_value)
|
|
need_value = False
|
|
else:
|
|
m = _RE_AAPT_STRING_RESOURCE_HEADER.match(line)
|
|
if m:
|
|
current_resource_id = int(m.group(1), 16)
|
|
current_resource_name = m.group(2)
|
|
need_value = True
|
|
|
|
return res_map
|
|
|
|
|
|
def main(args):
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument(
|
|
'input_file',
|
|
help='Input file path. This can be either an APK, or an app bundle.')
|
|
parser.add_argument('--output', help='Optional output file path.')
|
|
parser.add_argument(
|
|
'--omit-ids',
|
|
action='store_true',
|
|
help='Omit resource IDs in the output. This is useful '
|
|
'to compare the contents of two distinct builds of the '
|
|
'same APK.')
|
|
parser.add_argument(
|
|
'--aapt-path',
|
|
default=_AAPT_DEFAULT_PATH,
|
|
help='Path to aapt executable. Optional for APKs.')
|
|
parser.add_argument(
|
|
'--r-txt-path',
|
|
help='Path to an optional input R.txt file used to translate resource '
|
|
'IDs to string names. Useful when resources names in the input files '
|
|
'were obfuscated. NOTE: If ${INPUT_FILE}.R.txt exists, if will be used '
|
|
'automatically by this script.')
|
|
parser.add_argument(
|
|
'--bundletool-path',
|
|
default=_DEFAULT_BUNDLETOOL_PATH,
|
|
help='Path to alternate bundletool .jar file. Only used for bundles.')
|
|
parser.add_argument(
|
|
'--profile', action='store_true', help='Enable Python profiling.')
|
|
|
|
options = parser.parse_args(args)
|
|
|
|
# Create a {res_id -> res_name} map for unobfuscation, if needed.
|
|
res_id_name_map = {}
|
|
r_txt_path = options.r_txt_path
|
|
if not r_txt_path:
|
|
candidate_r_txt_path = options.input_file + '.R.txt'
|
|
if os.path.exists(candidate_r_txt_path):
|
|
r_txt_path = candidate_r_txt_path
|
|
|
|
if r_txt_path:
|
|
res_id_name_map = ReadStringMapFromRTxt(r_txt_path)
|
|
|
|
# Create a helper lambda that creates a new ResourceStringMap instance
|
|
# based on the input file's type.
|
|
if IsFilePathABundle(options.input_file):
|
|
if not options.bundletool_path:
|
|
parser.error(
|
|
'--bundletool-path <BUNDLETOOL_JAR> is required to parse bundles.')
|
|
|
|
# use bundletool to parse the bundle resources.
|
|
def create_string_map():
|
|
return ParseBundleResources(options.bundletool_path, options.input_file)
|
|
|
|
elif IsFilePathAnApk(options.input_file):
|
|
if not options.aapt_path:
|
|
parser.error('--aapt-path <AAPT> is required to parse APKs.')
|
|
|
|
# Use aapt dump to parse the APK resources.
|
|
def create_string_map():
|
|
return ParseApkResources(options.aapt_path, options.input_file)
|
|
|
|
else:
|
|
parser.error('Unknown file format: %s' % options.input_file)
|
|
|
|
# Print everything now.
|
|
with ManagedOutput(options.output) as output:
|
|
with ManagedPythonProfiling(options.profile):
|
|
res_map = create_string_map()
|
|
res_map.RemapResourceNames(res_id_name_map)
|
|
lines = AutoIndentStringList(res_map.ToStringList(options.omit_ids))
|
|
for line in lines:
|
|
output.write(line)
|
|
output.write('\n')
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv[1:])
|