├── .coveragerc ├── .flake8 ├── .github ├── assets │ └── example-terminal.png └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── Makefile ├── README.md ├── android_resources_checker ├── __init__.py ├── analyzer.py ├── app.py ├── entrypoint.py ├── files.py ├── models.py ├── reporting.py ├── resources.py └── validator.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── fixtures ├── dummy-anim.xml ├── dummy-class.kt ├── dummy-color.xml ├── dummy-dimens.xml ├── dummy-drawable.xml ├── dummy-layout.xml ├── dummy-styles.kt ├── dummy-styles.xml └── dummy-values-color.xml ├── test_reporting.py ├── test_resources_analyzer.py ├── test_resources_fetcher.py └── test_validator.py /.coveragerc: -------------------------------------------------------------------------------- 1 | 2 | [run] 3 | omit = 4 | android_resources_checker/app.py 5 | android_resources_checker/__init__.py -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length: 100 3 | exclude: .yml, .md -------------------------------------------------------------------------------- /.github/assets/example-terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbcbl/android-resources-checker/b4c35ab14789599775423bf4519b7348e0d1ce9b/.github/assets/example-terminal.png -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | main: 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | os: ['ubuntu-18.04'] 15 | python: ['3.8.8', '3.9.2'] 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - name: Project checkout 21 | uses: actions/checkout@v2.3.4 22 | 23 | - name: Setup Python 24 | uses: actions/setup-python@v2.2.1 25 | with: 26 | python-version: ${{ matrix.python }} 27 | 28 | - name: Install Poetry 29 | uses: snok/install-poetry@v1.1.2 30 | with: 31 | virtualenvs-create: false 32 | virtualenvs-in-project: false 33 | 34 | - name: Install dependencies 35 | run: make setup 36 | 37 | - name: Check codestyle 38 | run: make inspect 39 | 40 | - name: Run tests 41 | run: make test 42 | 43 | - name: Build package 44 | run: make build 45 | 46 | - name: Upload test reports to Codecov 47 | uses: codecov/codecov-action@v1.3.1 -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-18.04 11 | 12 | steps: 13 | - name: Project checkout 14 | uses: actions/checkout@v2.3.4 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v2.2.1 18 | with: 19 | python-version: '3.8.8' 20 | 21 | - name: Install Poetry 22 | uses: snok/install-poetry@v1.1.2 23 | with: 24 | virtualenvs-create: false 25 | virtualenvs-in-project: false 26 | 27 | - name: Install dependencies 28 | run: make setup 29 | 30 | - name: Publish to Pypi 31 | run: make deploy token=${{ secrets.PYPI_UPLOAD_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 141 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 142 | 143 | # User-specific stuff 144 | .idea/**/workspace.xml 145 | .idea/**/tasks.xml 146 | .idea/**/usage.statistics.xml 147 | .idea/**/dictionaries 148 | .idea/**/shelf 149 | 150 | # Generated files 151 | .idea/**/contentModel.xml 152 | 153 | # Sensitive or high-churn files 154 | .idea/**/dataSources/ 155 | .idea/**/dataSources.ids 156 | .idea/**/dataSources.local.xml 157 | .idea/**/sqlDataSources.xml 158 | .idea/**/dynamic.xml 159 | .idea/**/uiDesigner.xml 160 | .idea/**/dbnavigator.xml 161 | 162 | # Gradle 163 | .idea/**/gradle.xml 164 | .idea/**/libraries 165 | 166 | # Gradle and Maven with auto-import 167 | # When using Gradle or Maven with auto-import, you should exclude module files, 168 | # since they will be recreated, and may cause churn. Uncomment if using 169 | # auto-import. 170 | # .idea/artifacts 171 | # .idea/compiler.xml 172 | # .idea/jarRepositories.xml 173 | # .idea/modules.xml 174 | # .idea/*.iml 175 | # .idea/modules 176 | # *.iml 177 | # *.ipr 178 | 179 | # CMake 180 | cmake-build-*/ 181 | 182 | # Mongo Explorer plugin 183 | .idea/**/mongoSettings.xml 184 | 185 | # File-based project format 186 | *.iws 187 | 188 | # IntelliJ 189 | out/ 190 | 191 | # mpeltonen/sbt-idea plugin 192 | .idea_modules/ 193 | 194 | # JIRA plugin 195 | atlassian-ide-plugin.xml 196 | 197 | # Cursive Clojure plugin 198 | .idea/replstate.xml 199 | 200 | # Crashlytics plugin (for Android Studio and IntelliJ) 201 | com_crashlytics_export_strings.xml 202 | crashlytics.properties 203 | crashlytics-build.properties 204 | fabric.properties 205 | 206 | # Editor-based Rest Client 207 | .idea/httpRequests 208 | 209 | # Android studio 3.1+ serialized cache file 210 | .idea/caches/build_file_checksums.ser 211 | 212 | .idea/ 213 | .python-version 214 | 215 | # reports 216 | **.csv -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [UNRELEASED] 4 | 5 | # Version 0.0.10 6 | 7 | - Specify automatic deletion of unused resources via the `--delete` flag. 8 | - Fixes bugs in deletion that incorrectly deleted entries in file and whole files. 9 | 10 | # Version 0.0.9 11 | 12 | - Fix bug that was not considering resource names with '0' in the name. 13 | - Fix bug that was not considering all files of the project. 14 | 15 | ## Version 0.0.8 16 | 17 | - Add ability to use it as a validation tool using the `--check` option. 18 | - Specify the type of report using the `--report=(CSV|STDOUT)` (the default is to run all). 19 | - Fixes bug that didn't check for resource usages in the `AndroidManifest.xml` 20 | 21 | ## Version 0.0.7 22 | 23 | - Add inspection to `style` resources. 24 | - Update `LICENSE.md` and `README.md` with due copyright to Dotanuki Labs given that the structure/base of this project 25 | was highly inspired from [Bitrise Reports](https://github.com/dotanuki-labs/bitrise-reports). 26 | 27 | ## Version 0.0.6 28 | 29 | **2021-04-02** 30 | 31 | - Add CSV reports via the `--reports-dir` option. 32 | - Fixes bug that ignored that didn't process resources that are not xml (such as the ones usually placed on `raw`) 33 | 34 | ## Version 0.0.4 35 | 36 | **2021-04-01** 37 | 38 | - Add inspection to resources declared as entries (`string`, `color`, `dimen`) 39 | - Renamed `--app-path` to `--app` 40 | - Renamed `--client-path` to `--client` 41 | - Add ability to provide multiple clients. 42 | * This is done via the `--client` flag; you should use one for each of the clients. 43 | 44 | ## Version 0.0.3 45 | 46 | **2021-03-29** 47 | 48 | ### Features: 49 | 50 | - Identify the unused resources in your android project. 51 | - Identify the unused resources in your android library (when you have a multi-repo setup) 52 | - Listing of the unused resources (name, type and size) 53 | - Deletion of the unused resources 54 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Fábio Carballo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Install dependencies 3 | setup: 4 | poetry update 5 | poetry install 6 | poetry config virtualenvs.in-project true 7 | pip install flake8 8 | pip install black 9 | 10 | inspect: 11 | flake8 android_resources_checker tests 12 | black --check android_resources_checker tests 13 | 14 | build: 15 | poetry build 16 | 17 | test: 18 | poetry run pytest -vv --cov-report=xml --cov=android_resources_checker tests/ 19 | 20 | standard-inspection: 21 | poetry run android-resources-checker \ 22 | --app-path=$(app-path) 23 | 24 | ## Deploy the current build to Pypi 25 | deploy: 26 | poetry config pypi-token.pypi $(token) 27 | poetry build 28 | poetry publish -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Resources Checker 2 | 3 | [![Flake8](https://img.shields.io/badge/codestyle-flake8-yellow)](https://flake8.pycqa.org/en/latest/) 4 | [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 5 | [![Coverage](https://codecov.io/gh/fabiocarballo/android-resources-checker/branch/master/graph/badge.svg)](https://codecov.io/gh/fabiocarballo/android-resources-checker) 6 | [![License](https://img.shields.io/github/license/fabiocarballo/android-resources-checker)](https://choosealicense.com/licenses/mit) 7 | 8 | ## What 9 | 10 | This program will inspect the resources of your app and help you understand which ones are not being used and could 11 | potentially be removed. 12 | 13 | Main features: 14 | 15 | - Identify the unused resources in your android project. 16 | - Identify the unused resources in your android library (when you have a multi-repo setup) 17 | - Listing of the unused resources (name, type and size) 18 | - Deletion of the unused resources 19 | 20 | ## Installing 21 | 22 | This program requires Python, supporting from 3.8.x and 3.9.x 23 | 24 | In order to install run: 25 | 26 | ```shell 27 | pip install -U android-resources-checker 28 | ``` 29 | 30 | ## Using 31 | 32 | ## Inspecting your app resources. 33 | 34 | Imagining your app in the project `subject-app`, you can trigger the resources inspection by running: 35 | 36 | ```shell 37 | android-resources-checker --app /path/to/subject-app 38 | ``` 39 | 40 | ## Inspecting your library app resources. 41 | 42 | In the case you have two projects in separate repos, where a `client-app` depends on a `lib-app`, you can check the 43 | unused resources of the library app by running: 44 | 45 | ```shell 46 | android-resources-checker \ 47 | --app /path/to/lib-app \ 48 | --client /path/to/client-app-1 \ 49 | --client /path/to/client-app-2 50 | ``` 51 | 52 | An example of a run could look like this: 53 | 54 | ![](.github/assets/example-terminal.png) 55 | 56 | ## Reports 57 | 58 | The default behavior is to generate reports on both the stdout and CSV. 59 | 60 | You can specify a single type of report using the `--report=(CSV|STDOUT)` option. 61 | 62 | If using CSV reports, you can specify the directory where to write the reports in the form of CSV files. For that use 63 | the `--reports-dir` option. 64 | 65 | For example: 66 | 67 | ```shell 68 | android-resources-checker \ 69 | --app /path/to/app \ 70 | --reports-dir /path/to/reports 71 | ``` 72 | 73 | ## Validation 74 | 75 | There is also the option to run this as a validation tool. In this case, it will fail with an error if any unused 76 | resources are found. 77 | 78 | To specify the validation use the `--check` flag (the default behavior is to perform no validation). 79 | 80 | ## Automatic Deletion 81 | 82 | You can use this tool to also delete the unused resources by using the `--delete` option. 83 | 84 | ## License 85 | 86 | ``` 87 | 88 | Copyright (c) 2021 Dotanuki Labs, Fabio Carballo 89 | 90 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 91 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 92 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 93 | persons to whom the Software is furnished to do so, subject to the following conditions: 94 | 95 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 96 | Software. 97 | 98 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 99 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 100 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 101 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 102 | 103 | ``` 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /android_resources_checker/__init__.py: -------------------------------------------------------------------------------- 1 | from . import entrypoint 2 | 3 | 4 | def main(argv=None): 5 | entrypoint.launch() 6 | -------------------------------------------------------------------------------- /android_resources_checker/analyzer.py: -------------------------------------------------------------------------------- 1 | # analyzer.py 2 | from typing import Dict, Set 3 | 4 | from .models import AnalysisBreakdown, ResourceType, PackagedResource 5 | 6 | 7 | class ResourcesAnalyzer: 8 | def _create_breakdown( 9 | self, resources_list 10 | ) -> Dict[ResourceType, Set[PackagedResource]]: 11 | breakdown = {} 12 | for resource_type in ResourceType: 13 | resources_of_type = set( 14 | [ 15 | pr 16 | for pr in resources_list 17 | if pr.resource.resource_type == resource_type 18 | ] 19 | ) 20 | breakdown[resource_type] = resources_of_type 21 | 22 | return dict(sorted(breakdown.items(), key=lambda item: item[0].name)) 23 | 24 | def analyze(self, name, usage_references, packaged_resources) -> AnalysisBreakdown: 25 | used_packaged_resources = [] 26 | unused_packaged_resources = [] 27 | 28 | for pr in packaged_resources: 29 | if pr.resource in usage_references: 30 | used_packaged_resources.append(pr) 31 | else: 32 | unused_packaged_resources.append(pr) 33 | 34 | return AnalysisBreakdown( 35 | project_name=name, 36 | used_resources=self._create_breakdown(used_packaged_resources), 37 | unused_resources=self._create_breakdown(unused_packaged_resources), 38 | unused_size_bytes=sum([pr.size for pr in unused_packaged_resources]), 39 | ) 40 | -------------------------------------------------------------------------------- /android_resources_checker/app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from rich.console import Console 4 | 5 | from android_resources_checker.validator import UnusedResourcesException 6 | 7 | 8 | class Application(object): 9 | def __init__( 10 | self, resources_fetcher, resources_modifier, analyzer, reporter, validator 11 | ): 12 | self.resources_fetcher = resources_fetcher 13 | self.resources_modifier = resources_modifier 14 | self.analyzer = analyzer 15 | self.reporter = reporter 16 | self.validator = validator 17 | 18 | def execute(self, app_path, clients, check, delete): 19 | self.reporter.apps(app_path, clients) 20 | 21 | app_name = app_path.split("/")[-1] 22 | 23 | # fetch resources data 24 | console = Console() 25 | 26 | self.reporter.resources_processing_started() 27 | with console.status("[bold green]Processing project resources..."): 28 | packaged_resources = self.resources_fetcher.fetch_packaged_resources( 29 | app_path 30 | ) 31 | console.log(f"{app_name} - packaged resources processed!") 32 | 33 | usage_references = self.resources_fetcher.fetch_used_resources(app_path) 34 | console.log(f"{app_name} - used resources processed!") 35 | for client in clients: 36 | client_app_name = client.split("/")[-1] 37 | usage_references = usage_references.union( 38 | self.resources_fetcher.fetch_used_resources(client) 39 | ) 40 | console.log(f"{client_app_name} - used resources processed!") 41 | 42 | # analyze data 43 | analysis = self.analyzer.analyze( 44 | app_path.split("/")[-1], usage_references, packaged_resources 45 | ) 46 | 47 | # report 48 | self.reporter.reporting_started(analysis) 49 | self.reporter.report(analysis) 50 | 51 | # check 52 | if check: 53 | try: 54 | self.validator.validate(analysis) 55 | except UnusedResourcesException as e: 56 | paths = "\n".join([r.filepath for r in e.unused_resources]) 57 | self.reporter.error(f"\nUnused Resources have been found!\n{paths}") 58 | sys.exit(1) 59 | 60 | if delete: 61 | resources_to_delete = set.union(*analysis.unused_resources.values()) 62 | self.resources_modifier.delete_resources(resources_to_delete) 63 | self.reporter.deletion_completed(len(resources_to_delete)) 64 | -------------------------------------------------------------------------------- /android_resources_checker/entrypoint.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | from rich.console import Console 6 | 7 | from .analyzer import ResourcesAnalyzer 8 | from .app import Application 9 | from .files import FilesHandler 10 | from .reporting import Reporter, CsvAnalysisReporter, StdoutReporter 11 | from .resources import ResourcesFetcher, ResourcesModifier 12 | from .validator import Validator 13 | 14 | LIB_PROJECT_HELP = ( 15 | "The path to the android project whose resources you want to inspect." 16 | ) 17 | CLIENT_PROJECT_HELP = ( 18 | "The path to the client android project that consumes the reference" 19 | " project resources" 20 | ) 21 | 22 | 23 | @click.command() 24 | @click.option( 25 | "--app", 26 | type=click.Path(resolve_path=True, exists=True, file_okay=False), 27 | required=True, 28 | help=LIB_PROJECT_HELP, 29 | ) 30 | @click.option( 31 | "--client", 32 | type=click.Path(resolve_path=True, exists=True, file_okay=True), 33 | multiple=True, 34 | required=False, 35 | help=CLIENT_PROJECT_HELP, 36 | ) 37 | @click.option( 38 | "--reports-dir", 39 | type=click.Path(resolve_path=True, exists=True, file_okay=False), 40 | required=False, 41 | default=".", 42 | help="The directory where the csv reports will be written.", 43 | ) 44 | @click.option( 45 | "--check", 46 | is_flag=True, 47 | default=False, 48 | help="Using this flag will fail the execution if any unused resources are found", 49 | ) 50 | @click.option( 51 | "--report", 52 | type=click.Choice(["CSV", "STDOUT"], case_sensitive=False), 53 | required=False, 54 | ) 55 | @click.option( 56 | "--delete", 57 | is_flag=True, 58 | default=False, 59 | help="Using this flag will automatically delete the unused resources", 60 | ) 61 | def launch(app, client, report, reports_dir, check, delete): 62 | try: 63 | console = Console() 64 | error_console = Console(stderr=True, style="bold red") 65 | 66 | stdout_reporter = StdoutReporter(console) 67 | csv_reporter = CsvAnalysisReporter(console, reports_dir) 68 | choice_reporters = { 69 | None: [stdout_reporter, csv_reporter], 70 | "CSV": [csv_reporter], 71 | "STDOUT": [stdout_reporter], 72 | } 73 | 74 | application = Application( 75 | resources_fetcher=ResourcesFetcher(FilesHandler()), 76 | resources_modifier=ResourcesModifier(), 77 | analyzer=ResourcesAnalyzer(), 78 | reporter=Reporter(console, error_console, choice_reporters[report]), 79 | validator=Validator(), 80 | ) 81 | application.execute(app, client, check, delete) 82 | sys.exit(0) 83 | except Exception: 84 | logging.exception("Could not complete analysis.") 85 | sys.exit(1) 86 | -------------------------------------------------------------------------------- /android_resources_checker/files.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import xml.etree.ElementTree 4 | 5 | 6 | class FilesHandler: 7 | def resource_files(self, root): 8 | return [ 9 | os.path.join(d, x) 10 | for d, dirs, files in os.walk(root) 11 | for x in files 12 | if "/res/" in d 13 | ] 14 | 15 | def files_by_type(self, root, extension): 16 | return [ 17 | os.path.join(d, x) 18 | for d, dirs, files in os.walk(root) 19 | for x in files 20 | if x.endswith(extension) 21 | ] 22 | 23 | def java_kt_files(self, root): 24 | java_files = glob.glob(root + "/**/*.java", recursive=True) 25 | kotlin_files = glob.glob(root + "/**/*.kt", recursive=True) 26 | 27 | return java_files + kotlin_files 28 | 29 | def file_size(self, filepath): 30 | return os.stat(filepath).st_size 31 | 32 | def file_content(self, filepath): 33 | return open(filepath).readlines() 34 | 35 | def xml_tree(self, xml_filepath): 36 | return xml.etree.ElementTree.parse(xml_filepath) 37 | -------------------------------------------------------------------------------- /android_resources_checker/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | 4 | from typing import Dict, Set 5 | 6 | 7 | class ResourceType(Enum): 8 | drawable = "drawable" 9 | color = "color" 10 | anim = "anim" 11 | raw = "raw" 12 | dimen = "dimen" 13 | string = "string" 14 | style = "style" 15 | 16 | 17 | @dataclass(frozen=True) 18 | class PackagingType(Enum): 19 | file = "file" 20 | entry = "entry" 21 | 22 | 23 | @dataclass(frozen=True) 24 | class ResourceReference: 25 | name: str 26 | resource_type: ResourceType 27 | 28 | 29 | @dataclass(frozen=True) 30 | class PackagedResource: 31 | resource: ResourceReference 32 | packaging_type: PackagingType 33 | filepath: str 34 | size: int 35 | 36 | 37 | @dataclass(frozen=True) 38 | class AnalysisBreakdown: 39 | project_name: str 40 | used_resources: Dict[ResourceType, Set[PackagedResource]] 41 | unused_resources: Dict[ResourceType, Set[PackagedResource]] 42 | unused_size_bytes: int 43 | 44 | 45 | class ReportType(Enum): 46 | csv = "csv" 47 | stdout = "stdout" 48 | -------------------------------------------------------------------------------- /android_resources_checker/reporting.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from rich.table import Table 4 | 5 | from .models import ResourceType, AnalysisBreakdown, PackagingType 6 | 7 | HEADER_RESOURCE_TYPE = "Resource Type" 8 | HEADER_NUM_USED_RESOURCES = "Num used resources" 9 | HEADER_NUM_UNUSED_RESOURCES = "Num unused resources" 10 | HEADER_RESOURCE_NAME = "Resource Entry" 11 | HEADER_RESOURCE_LOCATION = "Resource Location" 12 | HEADER_RESOURCE_SIZE = "Resource Size" 13 | 14 | 15 | def _format_bytes(size): 16 | # 2**10 = 1024 17 | power = 2 ** 10 18 | n = 0 19 | power_labels = {0: "", 1: "kilo", 2: "mega", 3: "giga", 4: "tera"} 20 | while size > power: 21 | size /= power 22 | n += 1 23 | return f"{size:.2f} {str(power_labels[n])}bytes" 24 | 25 | 26 | def _format_to_kb(size): 27 | return f"{size / 2 ** 10:.2f} kb" 28 | 29 | 30 | class Reporter: 31 | def __init__(self, console, error_console, reporters): 32 | self.console = console 33 | self.error_console = error_console 34 | self.reporters = reporters 35 | 36 | def apps(self, lib_app_path, clients): 37 | self.__print("Reference app", lib_app_path, "cyan") 38 | for client in clients: 39 | self.__print("Client app", client, "cyan") 40 | 41 | def deletion_completed(self, num_resources): 42 | self.console.print(f"{num_resources} resources deleted! :rocket:") 43 | 44 | def resources_processing_started(self): 45 | self.console.print("\n[bold]Processing:[/bold]") 46 | 47 | def reporting_started(self, breakdown): 48 | self.console.print("\n[bold]Reporting:[/bold]") 49 | self.console.print( 50 | f"\nProject: [bold green]{breakdown.project_name}[/bold green]" 51 | ) 52 | self.console.print( 53 | f"\nPotential size savings → {_format_bytes(breakdown.unused_size_bytes)}" 54 | ) 55 | 56 | def report(self, breakdown): 57 | for reporter in self.reporters: 58 | reporter.report(breakdown) 59 | reporter.report_unused_resources_list(breakdown) 60 | 61 | def error(self, error): 62 | self.error_console.print(error) 63 | 64 | def __print(self, prefix, content, color): 65 | printer = self.console 66 | printer.print(f"{prefix} → [bold {color}]{content}[/bold {color}]") 67 | 68 | 69 | class AnalysisReporter: 70 | def __init__(self, console): 71 | self.console = console 72 | 73 | def report(self, breakdown: AnalysisBreakdown): 74 | pass 75 | 76 | def report_unused_resources_list(self, breakdown: AnalysisBreakdown): 77 | pass 78 | 79 | def _resources_usage_report_content(self, breakdown): 80 | content = [ 81 | [ 82 | HEADER_RESOURCE_TYPE, 83 | HEADER_NUM_USED_RESOURCES, 84 | HEADER_NUM_UNUSED_RESOURCES, 85 | ] 86 | ] 87 | for resource_type in sorted(ResourceType, key=lambda r: r.name): 88 | row = [ 89 | resource_type.name, 90 | str(len(breakdown.used_resources.get(resource_type, []))), 91 | str(len(breakdown.unused_resources.get(resource_type, []))), 92 | ] 93 | content.append(row) 94 | 95 | return content 96 | 97 | def _resources_unused_resources_report_content(self, breakdown): 98 | content = [ 99 | [HEADER_RESOURCE_NAME, HEADER_RESOURCE_LOCATION, HEADER_RESOURCE_SIZE] 100 | ] 101 | 102 | for grouped_resources in breakdown.unused_resources.values(): 103 | sorted_resources = sorted(grouped_resources, key=lambda r: r.filepath) 104 | for r in sorted_resources: 105 | resource_entry = r.filepath 106 | if r.packaging_type is PackagingType.entry: 107 | resource_entry += f" ({r.resource.name})" 108 | 109 | content.append( 110 | [ 111 | f"R.{r.resource.resource_type.name}.{r.resource.name}", 112 | r.filepath, 113 | _format_to_kb(r.size), 114 | ] 115 | ) 116 | 117 | return content 118 | 119 | 120 | class CsvAnalysisReporter(AnalysisReporter): 121 | def __init__(self, console, reports_dir): 122 | super().__init__(console) 123 | self.reports_dir = reports_dir 124 | 125 | def report(self, breakdown: AnalysisBreakdown): 126 | file = "resources_usage.csv" 127 | report_filepath = f"{self.reports_dir}/{file}" 128 | 129 | content = self._resources_usage_report_content(breakdown) 130 | 131 | with open(report_filepath, "w", newline="") as csv_file: 132 | csv_writer = csv.writer(csv_file, delimiter=";") 133 | for row in content: 134 | csv_writer.writerow(row) 135 | 136 | self.console.log(f"Resources usage breakdown written in {report_filepath}") 137 | 138 | def report_unused_resources_list(self, breakdown: AnalysisBreakdown): 139 | file = "unused_resources.csv" 140 | report_filepath = f"{self.reports_dir}/{file}" 141 | 142 | content = self._resources_unused_resources_report_content(breakdown) 143 | 144 | csv_writer = csv.writer(open(report_filepath, "w", newline=""), delimiter=";") 145 | for row in content: 146 | csv_writer.writerow(row) 147 | 148 | self.console.log(f"Unused resources list written in {report_filepath}") 149 | 150 | 151 | class StdoutReporter(AnalysisReporter): 152 | def report(self, breakdown: AnalysisBreakdown): 153 | printer = self.console 154 | printer.print("\n[bold green]Resources Usage[/bold green]") 155 | 156 | table = Table(show_header=True, header_style="bold magenta") 157 | table.pad_edge = False 158 | 159 | content = self._resources_usage_report_content(breakdown) 160 | 161 | for header in content[0]: 162 | table.add_column(header) 163 | 164 | for row in content[1:]: 165 | table.add_row(*row) 166 | 167 | printer.print(table) 168 | 169 | def report_unused_resources_list(self, breakdown): 170 | printer = self.console 171 | 172 | printer.print("\n[bold green]Unused Resources List[/bold green]") 173 | table = Table(show_header=True, header_style="bold magenta") 174 | table.pad_edge = False 175 | 176 | content = self._resources_unused_resources_report_content(breakdown) 177 | 178 | for header in content[0]: 179 | table.add_column(header) 180 | 181 | for row in content[1:]: 182 | table.add_row(*row) 183 | 184 | printer.print(table) 185 | -------------------------------------------------------------------------------- /android_resources_checker/resources.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from typing import Set 5 | import xml.etree.ElementTree as ET 6 | 7 | from .models import ResourceReference, ResourceType, PackagedResource, PackagingType 8 | 9 | 10 | class ResourcesFetcher: 11 | def __init__(self, files_handler): 12 | self.files_handler = files_handler 13 | 14 | def fetch_packaged_resources(self, project_path) -> Set[PackagedResource]: 15 | resource_files = self.files_handler.resource_files(project_path) 16 | xml_files = [f for f in resource_files if f.endswith(".xml")] 17 | 18 | file_resources = self._extract_file_resources(resource_files) 19 | entry_resources = self._extract_entry_resources(xml_files) 20 | 21 | return file_resources.union(file_resources.union(entry_resources)) 22 | 23 | def _extract_entry_resources(self, xml_files): 24 | resources = set() 25 | for filepath in xml_files: 26 | tree = self.files_handler.xml_tree(filepath) 27 | entry_resource_types = ["dimen", "string", "color", "style"] 28 | for resource_type in entry_resource_types: 29 | for entry in tree.findall(resource_type): 30 | resources.add( 31 | PackagedResource( 32 | resource=ResourceReference( 33 | entry.get("name"), ResourceType[resource_type] 34 | ), 35 | filepath=filepath, 36 | size=0, 37 | packaging_type=PackagingType.entry, 38 | ) 39 | ) 40 | 41 | return resources 42 | 43 | def _extract_file_resources(self, resource_files): 44 | resources = set() 45 | for filepath in resource_files: 46 | match = re.match(".*/(" + RESOURCES_OPTIONS + ").*/", filepath) 47 | if match is not None: 48 | filename = filepath.split("/")[-1] 49 | resource_name = filename.split(".")[0] 50 | resource_type = match.groups()[0] 51 | resource_size = self.files_handler.file_size(filepath) 52 | resource_ref = ResourceReference( 53 | resource_name, ResourceType[resource_type] 54 | ) 55 | 56 | resources.add( 57 | PackagedResource( 58 | resource=resource_ref, 59 | filepath=filepath, 60 | size=resource_size, 61 | packaging_type=PackagingType.file, 62 | ) 63 | ) 64 | 65 | return resources 66 | 67 | def fetch_used_resources(self, project_path) -> Set[ResourceReference]: 68 | resources = set() 69 | 70 | xml_regex = "@(" + RESOURCES_OPTIONS + ")/" + RESOURCE_NAME_REGEX 71 | for filepath in self.files_handler.files_by_type(project_path, extension="xml"): 72 | # look-up for styles 73 | styles_references = self._style_usages_in_xml(filepath) 74 | resources = resources.union(styles_references) 75 | 76 | for line in self.files_handler.file_content(filepath): 77 | for result in re.finditer(xml_regex, line): 78 | resource_reference = ResourceReference( 79 | result.group().split("/")[-1], 80 | ResourceType[result.groups()[0]], 81 | ) 82 | resources.add(resource_reference) 83 | 84 | code_regex = r"R\.(" + RESOURCES_OPTIONS + r")\." + RESOURCE_NAME_REGEX 85 | for filepath in self.files_handler.java_kt_files(project_path): 86 | for line in self.files_handler.file_content(filepath): 87 | for result in re.finditer(code_regex, line): 88 | resource_split = result.group().split(".") 89 | resource_type = ResourceType[resource_split[1]] 90 | resource_name = ( 91 | resource_split[-1] 92 | if resource_type is not ResourceType.style 93 | else resource_split[-1].replace("_", ".") 94 | ) 95 | 96 | resource_reference = ResourceReference(resource_name, resource_type) 97 | resources.add(resource_reference) 98 | 99 | return resources 100 | 101 | def _style_usages_in_xml(self, filepath): 102 | usages = set() 103 | 104 | style_usage_regexes = [ 105 | # @style/MyStyle 106 | r"@style/([A-Za-z\.]+)", 107 | # 21 | -------------------------------------------------------------------------------- /tests/fixtures/dummy-values-color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | #ABCDEF 5 | -------------------------------------------------------------------------------- /tests/test_reporting.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from typing import Any, Union 3 | 4 | import pytest 5 | from rich.console import Console, JustifyMethod, OverflowMethod 6 | from rich.style import Style 7 | from rich.table import Table 8 | 9 | from android_resources_checker.models import ( 10 | AnalysisBreakdown, 11 | ResourceType, 12 | PackagedResource, 13 | ResourceReference, 14 | PackagingType, 15 | ) 16 | from android_resources_checker.reporting import ( 17 | CsvAnalysisReporter, 18 | HEADER_RESOURCE_TYPE, 19 | HEADER_NUM_UNUSED_RESOURCES, 20 | HEADER_NUM_USED_RESOURCES, 21 | StdoutReporter, 22 | HEADER_RESOURCE_SIZE, 23 | HEADER_RESOURCE_LOCATION, 24 | HEADER_RESOURCE_NAME, 25 | ) 26 | 27 | 28 | def _create_test_packaged_resource( 29 | name, type, packaging_type=PackagingType.file, size=10 30 | ): 31 | return PackagedResource( 32 | ResourceReference(name, type), packaging_type, f"path/to/{name}.xml", size 33 | ) 34 | 35 | 36 | def test_csv_report_usage_breakdown(tmpdir, breakdown_for_usage): 37 | # Arrange 38 | reports_dir = tmpdir.mkdir("reports") 39 | reporter = CsvAnalysisReporter(Console(), reports_dir) 40 | 41 | # Act 42 | reporter.report(breakdown_for_usage) 43 | 44 | # Assert 45 | expected_csv_files = [ 46 | [HEADER_RESOURCE_TYPE, HEADER_NUM_USED_RESOURCES, HEADER_NUM_UNUSED_RESOURCES], 47 | ["anim", "0", "1"], 48 | ["color", "1", "0"], 49 | ["dimen", "0", "0"], 50 | ["drawable", "3", "2"], 51 | ["raw", "0", "0"], 52 | ["string", "0", "0"], 53 | ["style", "0", "0"], 54 | ] 55 | file_lines = [] 56 | with (open(f"{tmpdir}/reports/resources_usage.csv")) as f: 57 | reader = csv.reader(f, delimiter=";") 58 | for row in reader: 59 | file_lines.append(row) 60 | 61 | assert file_lines == expected_csv_files 62 | 63 | 64 | def test_csv_report_unused_resources(tmpdir, breakdown_for_unused_resources_list): 65 | # Arrange 66 | reports_dir = tmpdir.mkdir("reports") 67 | reporter = CsvAnalysisReporter(Console(), reports_dir) 68 | 69 | # Act 70 | reporter.report_unused_resources_list(breakdown_for_unused_resources_list) 71 | 72 | # Assert 73 | expected_csv_files = [ 74 | [HEADER_RESOURCE_NAME, HEADER_RESOURCE_LOCATION, HEADER_RESOURCE_SIZE], 75 | ["R.drawable.name1", "path/to/name1.xml", "1.95 kb"], 76 | ["R.drawable.name3", "path/to/name3.xml", "0.01 kb"], 77 | ["R.color.color1", "path/to/color1.xml", "0.00 kb"], 78 | ["R.color.color_selector", "path/to/color_selector.xml", "0.01 kb"], 79 | ["R.anim.anim1", "path/to/anim1.xml", "0.02 kb"], 80 | ] 81 | file_lines = [] 82 | with (open(f"{tmpdir}/reports/unused_resources.csv")) as f: 83 | reader = csv.reader(f, delimiter=";") 84 | for row in reader: 85 | file_lines.append(row) 86 | 87 | assert file_lines == expected_csv_files 88 | 89 | 90 | class FakeConsole(Console): 91 | def __init__(self): 92 | super().__init__() 93 | self.calls = [] 94 | 95 | def print( 96 | self, 97 | *objects: Any, 98 | sep=" ", 99 | end="\n", 100 | style: Union[str, Style] = None, 101 | justify: JustifyMethod = None, 102 | overflow: OverflowMethod = None, 103 | no_wrap: bool = None, 104 | emoji: bool = None, 105 | markup: bool = None, 106 | highlight: bool = None, 107 | width: int = None, 108 | height: int = None, 109 | crop: bool = True, 110 | soft_wrap: bool = None, 111 | ) -> None: 112 | self.calls.append(*objects) 113 | 114 | 115 | def test_stdout_unused_resources_list(breakdown_for_unused_resources_list): 116 | # Arrange 117 | console = FakeConsole() 118 | reporter = StdoutReporter(console) 119 | 120 | # Act 121 | reporter.report_unused_resources_list(breakdown_for_unused_resources_list) 122 | 123 | # Assert 124 | expected_table = Table(show_header=True, header_style="bold magenta") 125 | expected_table.pad_edge = True 126 | expected_table.add_column(HEADER_RESOURCE_NAME) 127 | expected_table.add_column(HEADER_RESOURCE_LOCATION) 128 | expected_table.add_column(HEADER_RESOURCE_SIZE) 129 | expected_table.add_row(*["R.drawable.name1", "path/to/name1.xml", "1.95 kb"]) 130 | expected_table.add_row(*["R.drawable.name3", "path/to/name3.xml", "0.01 kb"]) 131 | expected_table.add_row(*["R.color.color1", "path/to/color1.xml", "0.00 kb"]) 132 | expected_table.add_row( 133 | *["R.color.color_selector", "path/to/color_selector.xml", "0.01 kb"] 134 | ) 135 | expected_table.add_row(*["R.anim.anim1", "path/to/anim1.xml", "0.02 kb"]) 136 | 137 | expected_stdout = [ 138 | "\n[bold green]Unused Resources List[/bold green]", 139 | expected_table, 140 | ] 141 | 142 | assert console.calls[0] == expected_stdout[0] 143 | assert console.calls[1].columns == expected_stdout[1].columns 144 | assert console.calls[1].rows == expected_stdout[1].rows 145 | 146 | 147 | def test_stdout_report_usage_breakdown(breakdown_for_usage): 148 | # Arrange 149 | console = FakeConsole() 150 | reporter = StdoutReporter(console) 151 | 152 | # Act 153 | reporter.report(breakdown_for_usage) 154 | 155 | # Assert 156 | expected_table = Table(show_header=True, header_style="bold magenta") 157 | expected_table.pad_edge = True 158 | expected_table.add_column(HEADER_RESOURCE_TYPE) 159 | expected_table.add_column(HEADER_NUM_USED_RESOURCES) 160 | expected_table.add_column(HEADER_NUM_UNUSED_RESOURCES) 161 | expected_table.add_row(*["anim", "0", "1"]) 162 | expected_table.add_row(*["color", "1", "0"]) 163 | expected_table.add_row(*["dimen", "0", "0"]) 164 | expected_table.add_row(*["drawable", "3", "2"]) 165 | expected_table.add_row(*["raw", "0", "0"]) 166 | expected_table.add_row(*["string", "0", "0"]) 167 | expected_table.add_row(*["style", "0", "0"]) 168 | 169 | expected_stdout = ["\n[bold green]Resources Usage[/bold green]", expected_table] 170 | 171 | assert console.calls[0] == expected_stdout[0] 172 | assert console.calls[1].columns == expected_stdout[1].columns 173 | assert console.calls[1].rows == expected_stdout[1].rows 174 | 175 | 176 | @pytest.fixture 177 | def breakdown_for_usage(): 178 | return AnalysisBreakdown( 179 | project_name="test-project", 180 | used_resources={ 181 | ResourceType.drawable: { 182 | _create_test_packaged_resource("name1", ResourceType.drawable), 183 | _create_test_packaged_resource("name2", ResourceType.drawable), 184 | _create_test_packaged_resource("name3", ResourceType.drawable), 185 | }, 186 | ResourceType.color: { 187 | _create_test_packaged_resource("color1", ResourceType.color) 188 | }, 189 | }, 190 | unused_resources={ 191 | ResourceType.drawable: { 192 | _create_test_packaged_resource("name1", ResourceType.drawable), 193 | _create_test_packaged_resource("name3", ResourceType.drawable), 194 | }, 195 | ResourceType.color: {}, 196 | ResourceType.anim: { 197 | _create_test_packaged_resource("anim1", ResourceType.anim) 198 | }, 199 | }, 200 | unused_size_bytes=55, 201 | ) 202 | 203 | 204 | @pytest.fixture 205 | def breakdown_for_unused_resources_list(): 206 | return AnalysisBreakdown( 207 | project_name="test-project", 208 | used_resources={ 209 | ResourceType.drawable: { 210 | _create_test_packaged_resource("name3", ResourceType.drawable), 211 | }, 212 | ResourceType.color: { 213 | _create_test_packaged_resource("color1", ResourceType.color) 214 | }, 215 | }, 216 | unused_resources={ 217 | ResourceType.drawable: { 218 | _create_test_packaged_resource( 219 | "name1", ResourceType.drawable, size=2000 220 | ), 221 | _create_test_packaged_resource("name3", ResourceType.drawable, size=10), 222 | }, 223 | ResourceType.color: { 224 | _create_test_packaged_resource( 225 | "color1", ResourceType.color, PackagingType.entry, 1 226 | ), 227 | _create_test_packaged_resource( 228 | "color_selector", ResourceType.color, PackagingType.file, 10 229 | ), 230 | }, 231 | ResourceType.anim: { 232 | _create_test_packaged_resource( 233 | "anim1", ResourceType.anim, PackagingType.file, 20 234 | ) 235 | }, 236 | }, 237 | unused_size_bytes=55, 238 | ) 239 | -------------------------------------------------------------------------------- /tests/test_resources_analyzer.py: -------------------------------------------------------------------------------- 1 | from android_resources_checker.analyzer import ResourcesAnalyzer 2 | from android_resources_checker.models import ( 3 | AnalysisBreakdown, 4 | ResourceReference, 5 | ResourceType, 6 | PackagedResource, 7 | PackagingType, 8 | ) 9 | 10 | 11 | def _test_packaged_resource( 12 | index, resource_type, size=10, packaging_type=PackagingType.file 13 | ): 14 | name = f"{resource_type.name}_{index}" 15 | 16 | return PackagedResource( 17 | ResourceReference(name, resource_type), packaging_type, f"/path/{name}", size 18 | ) 19 | 20 | 21 | def test_analyze_resources_usage(): 22 | # Given 23 | name = "test" 24 | color_0 = _test_packaged_resource(0, ResourceType.color) 25 | color_1 = _test_packaged_resource(1, ResourceType.color, size=25) 26 | color_2 = _test_packaged_resource( 27 | 2, 28 | ResourceType.color, 29 | ) 30 | drawable_0 = _test_packaged_resource(0, ResourceType.drawable, size=50) 31 | drawable_1 = _test_packaged_resource(1, ResourceType.drawable, size=55) 32 | drawable_2 = _test_packaged_resource(2, ResourceType.drawable, size=40) 33 | analyzer = ResourcesAnalyzer() 34 | 35 | # Act 36 | breakdown = analyzer.analyze( 37 | name=name, 38 | usage_references=[ 39 | drawable_0.resource, 40 | color_0.resource, 41 | color_2.resource, 42 | drawable_2.resource, 43 | ], 44 | packaged_resources=[ 45 | color_0, 46 | color_1, 47 | color_2, 48 | drawable_0, 49 | drawable_1, 50 | drawable_2, 51 | ], 52 | ) 53 | 54 | # Assert 55 | expected_breakdown = AnalysisBreakdown( 56 | name, 57 | used_resources={ 58 | ResourceType.anim: set(), 59 | ResourceType.color: {color_0, color_2}, 60 | ResourceType.dimen: set(), 61 | ResourceType.drawable: {drawable_0, drawable_2}, 62 | ResourceType.raw: set(), 63 | ResourceType.string: set(), 64 | ResourceType.style: set(), 65 | }, 66 | unused_resources={ 67 | ResourceType.anim: set(), 68 | ResourceType.color: {color_1}, 69 | ResourceType.dimen: set(), 70 | ResourceType.drawable: {drawable_1}, 71 | ResourceType.raw: set(), 72 | ResourceType.string: set(), 73 | ResourceType.style: set(), 74 | }, 75 | unused_size_bytes=80, 76 | ) 77 | 78 | assert breakdown == expected_breakdown 79 | -------------------------------------------------------------------------------- /tests/test_resources_fetcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from android_resources_checker.files import FilesHandler 4 | from android_resources_checker.models import ( 5 | PackagedResource, 6 | ResourceReference, 7 | ResourceType, 8 | PackagingType, 9 | ) 10 | from android_resources_checker.resources import ResourcesFetcher 11 | import xml.etree.ElementTree as ET 12 | 13 | TEST_DIR = os.path.dirname(os.path.abspath(__file__)) 14 | 15 | 16 | class FakeFilesHandler(FilesHandler): 17 | def __init__(self, root_path, fake_config): 18 | self.root_path = root_path 19 | self.fake_config = fake_config 20 | 21 | def java_kt_files(self, root): 22 | return [ 23 | f 24 | for f in self.fake_config.keys() 25 | if f.endswith(".kt") or f.endswith(".java") 26 | ] 27 | 28 | def resource_files(self, root, extension="*"): 29 | if self.root_path == root: 30 | return [ 31 | f 32 | for f in self.fake_config.keys() 33 | if extension == "*" or f.endswith(extension) 34 | ] 35 | else: 36 | return [] 37 | 38 | def files_by_type(self, root, extension="*"): 39 | if self.root_path == root: 40 | return [ 41 | f 42 | for f in self.fake_config.keys() 43 | if extension == "*" or f.endswith(extension) 44 | ] 45 | else: 46 | return [] 47 | 48 | def file_size(self, filepath): 49 | return self.fake_config[filepath]["size"] 50 | 51 | def file_content(self, filepath): 52 | return self.fake_config[filepath]["content"] 53 | 54 | def xml_tree(self, filepath): 55 | return self.fake_config[filepath]["xml_content"] 56 | 57 | 58 | def test_fetch_packaged_resources(): 59 | # Given 60 | fake_files_handler = FakeFilesHandler( 61 | "root", 62 | { 63 | "path/to/drawable/file1.xml": { 64 | "size": 10, 65 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-drawable.xml"), 66 | }, 67 | "path/to/anim/file2.xml": { 68 | "size": 20, 69 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-anim.xml"), 70 | }, 71 | "path/to/color/file3.xml": { 72 | "size": 30, 73 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-color.xml"), 74 | }, 75 | "path/to/values/file4.xml": { 76 | "size": 40, 77 | "xml_content": ET.parse(f"{TEST_DIR}/fixtures/dummy-values-color.xml"), 78 | }, 79 | "path/to/raw/lottie.json": {"size": 500}, 80 | }, 81 | ) 82 | resources_fetcher = ResourcesFetcher(fake_files_handler) 83 | 84 | # Act 85 | resources = resources_fetcher.fetch_packaged_resources("root") 86 | 87 | # Assert 88 | assert resources == { 89 | PackagedResource( 90 | ResourceReference("file1", ResourceType.drawable), 91 | PackagingType.file, 92 | "path/to/drawable/file1.xml", 93 | 10, 94 | ), 95 | PackagedResource( 96 | ResourceReference("file2", ResourceType.anim), 97 | PackagingType.file, 98 | "path/to/anim/file2.xml", 99 | 20, 100 | ), 101 | PackagedResource( 102 | ResourceReference("file3", ResourceType.color), 103 | PackagingType.file, 104 | "path/to/color/file3.xml", 105 | 30, 106 | ), 107 | PackagedResource( 108 | ResourceReference("color_entry_0", ResourceType.color), 109 | PackagingType.entry, 110 | "path/to/values/file4.xml", 111 | 0, 112 | ), 113 | PackagedResource( 114 | ResourceReference("color_entry_1", ResourceType.color), 115 | PackagingType.entry, 116 | "path/to/values/file4.xml", 117 | 0, 118 | ), 119 | PackagedResource( 120 | ResourceReference("lottie", ResourceType.raw), 121 | PackagingType.file, 122 | "path/to/raw/lottie.json", 123 | 500, 124 | ), 125 | } 126 | 127 | 128 | def test_fetch_used_resources(): 129 | # Arrange 130 | fake_files_handler = FakeFilesHandler( 131 | "root", 132 | { 133 | "path/to/values/dummy-layout.xml": { 134 | "content": open(f"{TEST_DIR}/fixtures/dummy-layout.xml").readlines() 135 | }, 136 | "path/to/dummy.kt": { 137 | "content": open(f"{TEST_DIR}/fixtures/dummy-class.kt").readlines() 138 | }, 139 | }, 140 | ) 141 | 142 | resources_fetcher = ResourcesFetcher(fake_files_handler) 143 | 144 | # Act 145 | references = resources_fetcher.fetch_used_resources("root") 146 | 147 | # Assert 148 | assert references == { 149 | ResourceReference("background_white", ResourceType.drawable), 150 | ResourceReference("spacing_small", ResourceType.dimen), 151 | ResourceReference("dummy_string", ResourceType.string), 152 | ResourceReference("red", ResourceType.color), 153 | ResourceReference("spacing_normal", ResourceType.dimen), 154 | ResourceReference("background_red", ResourceType.drawable), 155 | ResourceReference("file3", ResourceType.color), 156 | ResourceReference("spacing_large", ResourceType.dimen), 157 | ResourceReference("drawable_used_programatically", ResourceType.drawable), 158 | ResourceReference("programatic_1", ResourceType.color), 159 | ResourceReference("programatic_2", ResourceType.color), 160 | } 161 | 162 | 163 | def test_styles_xml_usage_detection(): 164 | # Arrange 165 | fake_files_handler = FakeFilesHandler( 166 | "root", 167 | { 168 | "path/to/values/dummy-styles.xml": { 169 | "content": open(f"{TEST_DIR}/fixtures/dummy-styles.xml").readlines() 170 | }, 171 | }, 172 | ) 173 | 174 | resources_fetcher = ResourcesFetcher(fake_files_handler) 175 | 176 | # Act 177 | references = resources_fetcher.fetch_used_resources("root") 178 | 179 | # Assert 180 | expected_used_styles = { 181 | ResourceReference("BaseThemeA", ResourceType.style), 182 | ResourceReference("BaseThemeA.ThemeA", ResourceType.style), 183 | ResourceReference("BaseThemeB", ResourceType.style), 184 | ResourceReference("BaseThemeB.ThemeB", ResourceType.style), 185 | ResourceReference("BaseThemeC.BaseThemeD", ResourceType.style), 186 | ResourceReference("BaseThemeG", ResourceType.style), 187 | ResourceReference("BaseThemeG.ThemeG", ResourceType.style), 188 | } 189 | 190 | assert references == expected_used_styles 191 | 192 | 193 | def test_styles_programatic_usage_detection(): 194 | # Arrange 195 | fake_files_handler = FakeFilesHandler( 196 | "root", 197 | { 198 | "path/to/MyClass.kt": { 199 | "content": open(f"{TEST_DIR}/fixtures/dummy-styles.kt").readlines() 200 | }, 201 | }, 202 | ) 203 | 204 | resources_fetcher = ResourcesFetcher(fake_files_handler) 205 | 206 | # Act 207 | references = resources_fetcher.fetch_used_resources("root") 208 | 209 | # Assert 210 | expected_used_styles = { 211 | ResourceReference("MyTheme.ThemeA.ThemeB", ResourceType.style), 212 | ResourceReference("OtherTheme", ResourceType.style), 213 | } 214 | 215 | assert references == expected_used_styles 216 | -------------------------------------------------------------------------------- /tests/test_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from android_resources_checker.models import ( 4 | AnalysisBreakdown, 5 | ResourceType, 6 | PackagedResource, 7 | ResourceReference, 8 | PackagingType, 9 | ) 10 | from android_resources_checker.validator import Validator, UnusedResourcesException 11 | 12 | 13 | def test_validator_throws_exception_when_there_are_unused_resources(): 14 | validator = Validator() 15 | 16 | analysis = AnalysisBreakdown( 17 | "proj-test", 18 | {}, 19 | { 20 | ResourceType.drawable: { 21 | PackagedResource( 22 | ResourceReference("resource", ResourceType.drawable), 23 | PackagingType.entry, 24 | "filepath/to/file", 25 | 10, 26 | ) 27 | } 28 | }, 29 | 0, 30 | ) 31 | 32 | with pytest.raises(UnusedResourcesException): 33 | validator.validate(analysis) 34 | 35 | 36 | def test_validator_does_not_throw_exception_where_there_are_no_unused_resources(): 37 | validator = Validator() 38 | 39 | analysis = AnalysisBreakdown("proj-test", {}, {ResourceType.drawable: set()}, 0) 40 | 41 | validator.validate(analysis) 42 | --------------------------------------------------------------------------------