#!/usr/bin/env python3
# Copyright 2021 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
r"""Creates Android resources directories and boilerplate files for a module.
This is a utility script for conveniently creating resources directories and
values .xml files in modules prefilled with boilerplate and example usages. It
prints out suggested changes to the BUILD.gn and will apply them if accepted.
Examples:
Touch colors.xml and styles.xml in module foo:
tools/android/modularization/convenience/touch_resources.py \
chrome/browser/foo \
-v colors styles
Touch dimens.xml in module foo's internal dir for hdpi, xhdpi and xxdpi:
tools/android/modularization/convenience/touch_resources.py \
chrome/browser/foo/internal \
-v dimens \
-q hdpi xhdpi xxhdpi
Touch drawable directories in module foo for hdpi, xhdpi and xxdpi:
tools/android/modularization/convenience/touch_resources.py \
chrome/browser/foo \
-d drawable \
-q hdpi xhdpi xxhdpi
"""
import argparse
import datetime
import pathlib
from typing import List, Optional, Tuple
import build_gn_editor
_IGNORED_FILES_IN_RES = {'DIR_METADATA', 'OWNERS'}
_VALUES_SUPPORTED = [
'arrays',
'colors',
'dimens',
'ids',
'strings',
'styles',
]
_DIRS_SUPPORTED = [
'animator',
'anim',
'color',
'drawable',
'font',
'mipmap',
'layout',
'menu',
'raw',
'values',
'xml',
]
def main():
arg_parser = argparse.ArgumentParser(
description='Creates Android resources directories and boilerplate files '
'for a module.')
arg_parser.add_argument('module',
help='Module directory to create resources for. e.g. '
'chrome/browser/foo')
arg_parser.add_argument('-v',
'--values',
nargs='+',
default=[],
choices=_VALUES_SUPPORTED,
help='Creates values .xml resources files that do '
'not exist yet.')
arg_parser.add_argument(
'-d',
'--directories',
nargs='+',
default=[],
choices=_DIRS_SUPPORTED,
help='Creates resources file directories that do not exist yet. '
'Use --values to create the values directory.')
arg_parser.add_argument(
'-q',
'--qualifiers',
nargs='+',
help='If specified, resources will be created under these Android '
'resources qualifiers. See '
'https://developer.android.com/guide/topics/resources/providing-resources#AlternativeResources'
)
arguments = arg_parser.parse_args()
# Recognize directory structure and determine the existing BUILD.gn location
# and where resources are or should be
build_gn_path, resources_path = _identify_module_structure(arguments.module)
# Create res/ directory if it does not exist
if not resources_path.is_dir():
resources_path.mkdir(parents=True)
print(f'Created resources directory: {resources_path}')
# Detect existing resources
all_resources = [
p for p in resources_path.rglob('*')
if p.is_file() and p.name not in _IGNORED_FILES_IN_RES
]
changes_requested = False
new_resources = []
# Process -q/--qualifiers
if not arguments.qualifiers:
qualifier_suffixes = ['']
else:
qualifier_suffixes = [f'-{qualifier}' for qualifier in arguments.qualifiers]
# Process -v/--values
for value_type in arguments.values:
changes_requested = True
if value_type == 'strings':
raise ValueError(
'strings.xml files are replaced by strings.grd files for '
'localization, and modules do not need to create separate '
'strings.grd files. Existing strings can be left in and new strings '
'can be added to '
'chrome/browser/ui/android/strings/android_chrome_strings.grd')
created_resources = _touch_values_files(resources_path, value_type,
qualifier_suffixes)
new_resources.extend(created_resources)
all_resources.extend(created_resources)
# Process -d/--directories
for subdirectory in arguments.directories:
changes_requested = True
if subdirectory == 'values':
raise ValueError(
'Use -v/--values to create the values directory and values resources.'
)
_touch_subdirectories(resources_path, subdirectory, qualifier_suffixes)
if not changes_requested:
print('No resource types specified to create, so just created the res/ '
'directory. Use -v/--values to create value resources and '
'-d/--directories to create resources subdirectories.')
# Print out build target suggestions
all_resources.sort(key=str)
if not all_resources:
return
build_file = build_gn_editor.BuildFile(build_gn_path)
build_gn_changes_ok = _update_build_file(build_file, all_resources)
if not build_gn_changes_ok:
_print_build_target_suggestions(build_gn_path, all_resources)
return
print('Final delta:')
print(build_file.get_diff())
apply_changes = _yes_or_no('Would you like to apply these changes?')
if not apply_changes:
return
build_file.write_content_to_file()
def _yes_or_no(question: str) -> bool:
val = input(question + ' [(y)es/(N)o] ')
try:
y_or_n = val.lower().strip()
return y_or_n[0] == 'y'
except Exception:
print('Invalid input. Assuming No.')
return False
def _determine_target_to_use(targets: List[str], target_type: str,
default_name: str) -> Optional[str]:
num_targets = len(targets)
if not num_targets:
print(f'Found no existing {target_type} will create ":{default_name}".')
return default_name
if num_targets == 1:
print(f'Found existing target {target_type}("{targets[0]}"), using it.')
return targets[0]
print(f'Found multiple existing {target_type} targets, pick one: ')
return _enumerate_targets_and_ask(targets)
def _enumerate_targets_and_ask(targets: List[str]) -> Optional[str]:
for i, target in enumerate(targets):
print(f'{i + 1}: {target}')
try:
val = int(
input('Enter the number corresponding the to target you want to '
'use: ')) - 1
except ValueError:
return None
if 0 <= val < len(targets):
return targets[val]
return None
def _identify_module_structure(path_argument: str
) -> Tuple[pathlib.Path, pathlib.Path]:
module_path = pathlib.Path(path_argument)
assert module_path.is_dir()
# If present, prefer module/android/BUILD.gn
possible_android_path = module_path / 'android'
if possible_android_path.is_dir():
possible_build_gn_path = possible_android_path / 'BUILD.gn'
if possible_build_gn_path.is_file():
build_gn_path = possible_build_gn_path
resources_path = possible_android_path / 'java' / 'res'
return build_gn_path, resources_path
# The recommended structure is module/BUILD.gn
possible_build_gn_path = module_path / 'BUILD.gn'
if possible_build_gn_path.is_file():
build_gn_path = possible_build_gn_path
possible_existing_java_path = module_path / 'java'
# If module/java exists, use module/java/res, but the preferred structure is
# module/android/java/res
if possible_existing_java_path.is_dir():
resources_path = possible_existing_java_path / 'res'
else:
resources_path = possible_android_path / 'java' / 'res'
return build_gn_path, resources_path
raise Exception(
f'BUILD.gn found neither in {module_path} nor in {possible_android_path}')
def _touch_values_files(resources_path: pathlib.Path, value_resource_type: str,
qualifier_suffixes: List[str]) -> List[pathlib.Path]:
created_files = []
for qualifier_suffix in qualifier_suffixes:
values_path = resources_path / f'values{qualifier_suffix}'
values_path.mkdir(parents=True, exist_ok=True)
xml_path = values_path / f'{value_resource_type}.xml'
if xml_path.is_file():
print(f'{xml_path} already exists.')
continue
with xml_path.open('a') as f:
f.write(_create_filler(value_resource_type))
print(f'Created {xml_path}')
created_files.append(xml_path)
return created_files
_RESOURCES_BOILERPLATE_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright {year} The Chromium Authors
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
{contents}
</resources>
"""
_DIMENS_BOILERPLATE = """ <!-- Foo icon dimensions -->
<dimen name="foo_icon_height">24dp</dimen>
<dimen name="foo_icon_width">24dp</dimen>"""
_COLORS_BOILERPLATE = """ <!-- Foo UI colors -->
<color name="foo_background_color">@color/default_bg_color_light</color>"""
_STYLES_BOILERPLATE = """ <!-- Styling for a Foo menu button. -->
<style name="FooMenuButton">
<item name="android:layout_width">48dp</item>
<item name="android:layout_height">24dp</item>
<item name="tint">@color/default_icon_color_tint_list</item>
</style>"""
_IDS_BOILERPLATE = """ <!-- Dialog button ids -->
<item type="id" name="foo_ok_button" />
<item type="id" name="foo_cancel_button" />"""
_ARRAYS_BOILERPLATE = """ <!-- Prime numbers -->
<integer-array name="foo_primes">
<item>2</item>
<item>3</item>
<item>5</item>
<item>7</item>
</integer-array>
<!-- Geometrics shapes -->
<array name="foo_shapes">
<item>@drawable/triangle</item>
<item>@drawable/square</item>
<item>@drawable/circle</item>
</array>"""
_BOILERPLATE = {
'dimens': _DIMENS_BOILERPLATE,
'colors': _COLORS_BOILERPLATE,
'styles': _STYLES_BOILERPLATE,
'ids': _IDS_BOILERPLATE,
'arrays': _ARRAYS_BOILERPLATE
}
def _create_filler(value_resource_type: str) -> str:
boilerplate = _BOILERPLATE[value_resource_type]
return _RESOURCES_BOILERPLATE_TEMPLATE.format(year=_get_current_year(),
contents=boilerplate)
def _get_current_year() -> int:
return datetime.datetime.now().year
_COMMON_RESOURCE_DEPS = [
"//chrome/browser/ui/android/strings:ui_strings_grd",
"//components/browser_ui/strings/android:browser_ui_strings_grd",
"//components/browser_ui/styles/android:java_resources",
"//components/browser_ui/widget/android:java_resources",
"//third_party/android_deps:material_design_java",
"//ui/android:ui_java_resources",
]
def _touch_subdirectories(resources_path: pathlib.Path, subdirectory: str,
qualifier_suffixes: List[str]) -> List[pathlib.Path]:
for qualifier_suffix in qualifier_suffixes:
subdir_name = f'{subdirectory}{qualifier_suffix}'
subdir_path = resources_path / subdir_name
if not subdir_path.is_dir():
subdir_path.mkdir(parents=True)
print(f'Created {subdir_path}')
else:
print(f'{subdir_path} already exists.')
def _generate_resources_sources(build_gn_dir_path: pathlib.Path,
new_resources: List[pathlib.Path]) -> List[str]:
return [f'"{str(r.relative_to(build_gn_dir_path))}"' for r in new_resources]
def _list_to_lines(lines: List[str], indent: int) -> str:
spaces = ' ' * indent
return '\n'.join([f'{spaces}{line},' for line in lines])
def _generate_suggested_resources_deps() -> List[str]:
return [f'# "{dep}"' for dep in _COMMON_RESOURCE_DEPS]
def _generate_resources_content(build_gn_path: pathlib.Path,
new_resources: List[pathlib.Path], *,
include_comment: bool) -> str:
build_gn_dir_path = build_gn_path.parent
new_resources_lines = _list_to_lines(
_generate_resources_sources(build_gn_dir_path, new_resources), 4)
suggested_deps_lines = _list_to_lines(_generate_suggested_resources_deps(), 4)
comment = ''
if include_comment:
comment = ('\n # Commonly required resources deps for convenience, ' +
'add other required deps and remove unnecessary ones.')
resources_content = f"""sources = [
{new_resources_lines}
]
deps = [{comment}
{suggested_deps_lines}
]"""
return resources_content
def _generate_suggested_resources(build_gn_path: pathlib.Path,
new_resources: List[pathlib.Path]) -> str:
resources_content = _generate_resources_content(build_gn_path,
new_resources,
include_comment=True)
resources_target_suggestion = f"""
android_resources("java_resources") {{
{resources_content}
}}"""
return resources_target_suggestion
def _generate_suggested_java_package(build_gn_path: pathlib.Path) -> str:
build_gn_dir_path = build_gn_path.parent
parts_for_package = build_gn_dir_path.parts
# internal, public or android subdirectories are not part of the Java package.
while parts_for_package[-1] in ('internal', 'public', 'android'):
parts_for_package = parts_for_package[:-1]
return f'org.chromium.{".".join(parts_for_package)}'
def _generate_library_content(build_gn_path: pathlib.Path,
resources_target_name: str) -> str:
suggested_java_package = _generate_suggested_java_package(build_gn_path)
library_content = f"""deps = [
":{resources_target_name}",
]
resources_package = "{suggested_java_package}" """
return library_content
def _generate_library_target(build_gn_path: pathlib.Path,
resources_target_name: str) -> str:
library_content = _generate_library_content(build_gn_path,
resources_target_name)
android_library_target_suggestion = f"""
android_library("java") {{
{library_content}
}}"""
return android_library_target_suggestion
def _create_or_update_variable_list(target: build_gn_editor.BuildTarget,
variable_name: str,
elements: List[str]) -> None:
variable = target.get_variable(variable_name)
if variable:
variable_list = variable.get_content_as_list()
if not variable_list:
raise build_gn_editor.BuildFileUpdateError(
f'{target.get_type()}("{target.get_name()}") '
f'{variable_name} is not a list.')
variable_list.add_elements(elements)
variable.set_content_from_list(variable_list)
target.replace_variable(variable)
return
variable = build_gn_editor.TargetVariable(variable_name, '')
variable_list = build_gn_editor.VariableContentList()
variable_list.add_elements(elements)
variable.set_content_from_list(variable_list)
target.add_variable(variable)
def _update_build_file(build_file: build_gn_editor.BuildFile,
all_resources: List[pathlib.Path]) -> bool:
libraries = build_file.get_target_names_of_type('android_library')
resources = build_file.get_target_names_of_type('android_resources')
library_target = _determine_target_to_use(libraries, 'android_library',
'java')
resources_target = _determine_target_to_use(resources, 'android_resources',
'java_resources')
if not library_target or not resources_target:
print('Invalid build target selections. Aborting BUILD.gn changes.')
return False
try:
_update_build_targets(build_file, all_resources, library_target,
resources_target)
except build_gn_editor.BuildFileUpdateError as e:
print(f'Changes to build targets failed: {e}. Aborting BUILD.gn changes.')
return False
try:
build_file.format_content()
except build_gn_editor.BuildFileUpdateError as e:
print(f'Formatting BUILD gn failed: {e}\n Aborting BUILD.gn changes')
return False
return True
def _update_build_targets(build_file: build_gn_editor.BuildFile,
all_resources: List[pathlib.Path],
library_target: str, resources_target: str) -> None:
resources = build_file.get_target('android_resources', resources_target)
if not resources:
resources = build_gn_editor.BuildTarget(
'android_resources', resources_target,
_generate_resources_content(build_file.get_path(),
all_resources,
include_comment=False))
build_file.add_target(resources)
else:
_create_or_update_variable_list(
resources, 'sources',
_generate_resources_sources(build_file.get_path().parent,
all_resources))
_create_or_update_variable_list(resources, 'deps',
_generate_suggested_resources_deps())
build_file.replace_target(resources)
library = build_file.get_target('android_library', library_target)
if not library:
library = build_gn_editor.BuildTarget(
'android_library', library_target,
_generate_library_content(build_file.get_path(), resources_target))
build_file.add_target(library)
else:
_create_or_update_variable_list(library, 'deps', [f'":{resources_target}"'])
resources_package = library.get_variable('resources_package')
if not resources_package:
resources_package_str = _generate_suggested_java_package(
build_file.get_path())
library.add_variable(
build_gn_editor.TargetVariable('resources_package',
f'"{resources_package_str}"'))
build_file.replace_target(library)
def _print_build_target_suggestions(build_gn_path: pathlib.Path,
new_resources: List[pathlib.Path]) -> None:
resources_target_suggestion = _generate_suggested_resources(
build_gn_path, new_resources)
android_library_target_suggestion = _generate_library_target(
build_gn_path, 'java_resources')
print(f'Suggestion for {build_gn_path}:')
print(resources_target_suggestion)
print(android_library_target_suggestion)
print()
if __name__ == '__main__':
main()