├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── library ├── android-library-config.gradle ├── gradle.properties ├── lint.xml ├── pack.gradle ├── release.gradle ├── scripts │ ├── .gitignore │ ├── assemble_string_packs.py │ ├── find_movable_strings.py │ ├── id_finder.py │ ├── move_strings_for_packing.py │ ├── pack_strings.py │ ├── resources │ │ ├── __init__.py │ │ └── default_config.json │ ├── string_pack.py │ ├── string_pack_config.py │ └── tests │ │ ├── README.md │ │ ├── __init__.py │ │ ├── res │ │ ├── Expected.java │ │ ├── Test.java │ │ ├── expected_resources.txt │ │ ├── test_layout.xml │ │ ├── test_move_resources.xml │ │ ├── test_resources.xml │ │ ├── test_resources_en.xml │ │ └── unused_resource.txt │ │ ├── test_find_movable_strings.py │ │ ├── test_move_strings_for_packing.py │ │ ├── test_pack_strings.py │ │ ├── test_string_pack.py │ │ ├── test_string_pack_config.py │ │ └── test_util.py ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── com │ │ │ └── whatsapp │ │ │ └── stringpacks │ │ │ ├── Logger.java │ │ │ ├── MMappedStringPack.java │ │ │ ├── ParsedStringPack.java │ │ │ ├── PluralRules.java │ │ │ ├── SpLog.java │ │ │ ├── StringPackContext.java │ │ │ ├── StringPackData.java │ │ │ ├── StringPackResources.java │ │ │ ├── StringPackUtils.java │ │ │ ├── StringPacks.java │ │ │ ├── StringPacksLocaleMetaDataProvider.java │ │ │ ├── receiver │ │ │ └── MyPackageReplacedReceiver.java │ │ │ ├── service │ │ │ └── PackFileDeletionService.java │ │ │ └── utils │ │ │ ├── ContextUtils.java │ │ │ └── FileUtils.java │ └── test │ │ ├── java │ │ └── com │ │ │ └── whatsapp │ │ │ └── stringpacks │ │ │ ├── MMappedStringPackTest.java │ │ │ ├── StringPacksTest.java │ │ │ └── StringPacksTestData.java │ │ └── resources │ │ ├── README.md │ │ ├── robolectric.properties │ │ ├── strings_ha.pack │ │ ├── strings_zh-rTW.pack │ │ └── strings_zh.pack └── templates │ ├── StringPackAndroidIdsRange.java │ ├── StringPackIds.java │ ├── StringPackIds.kt │ └── config.json └── sample ├── README.md ├── app ├── .gitignore ├── build.gradle ├── gradle.properties └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── whatsapp │ │ │ └── stringpacks │ │ │ └── sample │ │ │ ├── LanguageChangeHandler.java │ │ │ ├── LocaleMetaDataProviderImpl.java │ │ │ ├── LocaleUtil.java │ │ │ ├── MainActivity.java │ │ │ ├── SampleApplication.java │ │ │ └── StringPackIds.java │ ├── res │ │ ├── drawable │ │ │ └── ic_launcher.png │ │ ├── layout │ │ │ └── activity_main.xml │ │ ├── values-ar │ │ │ └── strings.xml │ │ ├── values-es │ │ │ └── strings.xml │ │ ├── values-fr │ │ │ └── strings.xml │ │ ├── values-ha-rNG │ │ │ └── strings.xml │ │ ├── values-zh-rTW │ │ │ └── strings.xml │ │ ├── values-zh │ │ │ └── strings.xml │ │ └── values │ │ │ └── strings.xml │ └── string-packs │ │ ├── config.json │ │ └── strings │ │ ├── values-ar │ │ └── strings.xml │ │ ├── values-es │ │ └── strings.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-ha-rNG │ │ └── strings.xml │ │ ├── values-zh-rTW │ │ └── strings.xml │ │ └── values-zh │ │ └── strings.xml │ └── test │ ├── java │ └── com │ │ └── whatsapp │ │ └── stringpacks │ │ └── sample │ │ └── LocaleUtilTest.java │ └── resources │ ├── robolectric.properties │ ├── strings_ha.pack │ ├── strings_zh-rTW.pack │ └── strings_zh.pack ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | !/.idea/codeStyles/** 2 | *.iml 3 | *.swo 4 | *.swp 5 | *~ 6 | .DS_Store 7 | .gradle/ 8 | .idea/ 9 | build/ 10 | local.properties 11 | library/BUCK 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Facebook has adopted a Code of Conduct that we expect project participants to adhere to. 4 | Please read the [full text](https://code.fb.com/codeofconduct/) 5 | so that you can understand what actions will and will not be tolerated. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to WhatsApp StringPacks 2 | We want to make contributing to this project as easy and transparent as 3 | possible. 4 | 5 | ## Pull Requests 6 | We actively welcome your pull requests. 7 | 8 | 1. Fork the repo and create your branch from `main`. 9 | 2. If you've added code that should be tested, add tests. 10 | 3. If you've changed APIs, update the documentation. 11 | 4. Ensure the test suite passes. 12 | 5. Make sure your code lints. 13 | 6. If you haven't already, complete the Contributor License Agreement ("CLA"). 14 | 15 | ## Contributor License Agreement ("CLA") 16 | In order to accept your pull request, we need you to submit a CLA. You only need 17 | to do this once to work on any of Facebook's open source projects. 18 | 19 | Complete your CLA here: 20 | 21 | ## Issues 22 | We use GitHub issues to track public bugs. Please ensure your description is 23 | clear and has sufficient instructions to be able to reproduce the issue. 24 | 25 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 26 | disclosure of security bugs. In those cases, please go through the process 27 | outlined on that page and do not file a public issue. 28 | 29 | ## Coding Style 30 | For Android, we follow Google Java Style Guide as the coding style: . 31 | 32 | ## License 33 | By contributing to WhatsApp StringPacks, you agree that your contributions will be licensed 34 | under the LICENSE file in the root directory of this source tree. 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StringPacks 2 | 3 | StringPacks is a library to store translation strings in a more efficient binary format for Android applications, so that it reduces the Android APK size. 4 | 5 | Check out [our tech talk on StringPacks from DroidCon SF 2019](https://youtu.be/npnamYPQD3g?t=812) to know more about the motivation, architecture and prospect of the StringPacks project. 6 | 7 | ## Requirements 8 | 9 | - **Python 3** - The StringPacks python scripts are written in Python 3. 10 | - **minSdkVersion 15** - The library default min sdk version is 15, but it should work for lower SDK versions. 11 | - **Git** - The script uses `git ls-files` to look up files. 12 | - **Android development environment** 13 | - **Gradle Build System** 14 | 15 | 16 | ## Setup in Android Project 17 | 18 | 1. Copy the [scripts/](library/scripts/) and [pack.gradle](library/pack.gradle) from `library/` to the root directory of your Android project. 19 | 2. Move either [Java](library/templates/StringPackIds.java) or [Kotlin](library/templates/StringPackIds.kt) version of `StringPackIds` file from [templates/](library/templates/) directory to your project source code directory. 20 | - Edit package information of the file. 21 | 3. Move [template config.json](library/templates/config.json) to your Android application project directory. 22 | - Replace `{app}` to be your application project directory name. 23 | - Choose one of the two (mutually exclusive): 24 | - Point `pack_ids_class_file_path` to the path where you put the `StringPackIds` file. 25 | - Configure `resource_config_setting` to generate the necessary aapt config file. 26 | - `config_file_path` file path for aapt2 config to set stable id 27 | - `source_file_path` for file path of generated Java source 28 | - `string_offset` a hex string for string id offset (usually "0x7f120000") 29 | - `plurals_offset` a hex string for plural id offset (usually "0x7f100000") 30 | - `package_name` for package name. 31 | 4. Make following changes to your Android project's `build.gradle`. 32 | ``` 33 | allprojects { 34 | 35 | repositories { 36 | ... 37 | mavenCentral() 38 | } 39 | ... 40 | } 41 | 42 | // Replace `{path_to_config.json}` with the path to your `config.json` file 43 | ext { 44 | stringPacksConfigFile = "$rootDir/{path_to_config.json}" 45 | } 46 | ``` 47 | - Replace `{path_to_config.json}` with the path to your `config.json` file 48 | 5. Make following changes to your Android application's `build.gradle` 49 | ``` 50 | apply from: "$rootDir/pack.gradle" 51 | 52 | dependencies { 53 | ... 54 | ... 55 | implementation 'com.whatsapp.stringpacks:stringpacks:0.3.1' 56 | } 57 | ``` 58 | 6. To remove old `.pack` files from the device's internal storage, on every app upgrade, add [MyPackageReplacedReceiver.java](library/src/main/java/com/whatsapp/stringpacks/receiver/MyPackageReplacedReceiver.java) and [PackFileDeletionService.java](library/src/main/java/com/whatsapp/stringpacks/service/PackFileDeletionService.java) to your `AndroidManifest.xml` 59 | ``` 60 | 61 | 62 | ... 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ``` 71 | > Note: If you want to delete old `.pack` files, from internal storage, at some other time instead of app upgrade, call `StringPacks.cleanupOldPackFiles(getApplicationContext())` whenever you want. You don't have to include [MyPackageReplacedReceiver.java](library/src/main/java/com/whatsapp/stringpacks/receiver/MyPackageReplacedReceiver.java) or [PackFileDeletionService.java](library/src/main/java/com/whatsapp/stringpacks/service/PackFileDeletionService.java) in your `AndroidManifest.xml` 72 | 73 | You now have StringPacks available in your Android project. 74 | 75 | ## Getting Started 76 | 77 | There are a few steps to walk through before you can really use packed strings in your application. But don't worry, most of them only need to be done once. 78 | 79 | ### Runtime 80 | 81 | Since the translated strings are moved to our special binary format (`.pack` files), your application needs a way to read those strings during runtime. The library provides a wrapper class for [`Context`](https://developer.android.com/reference/android/content/ContextWrapper) and [`Resources`](https://developer.android.com/reference/android/content/res/Resources) to help with that. 82 | 83 | You need to add the following code to all subclasses of your Context class (like [`Activity`](https://developer.android.com/reference/android/app/Activity) and [`Service`](https://developer.android.com/reference/android/app/Service)) to ensure the strings are read from `.pack` files instead of Android system resources. 84 | 85 | ```java 86 | // Java 87 | 88 | @Override 89 | protected void attachBaseContext(Context base) { 90 | super.attachBaseContext(StringPackContext.wrap(base)); 91 | } 92 | ``` 93 | 94 | ```kotlin 95 | // Kotlin 96 | 97 | override fun attachBaseContext(base: Context?) { 98 | super.attachBaseContext(StringPackContext.wrap(base)) 99 | } 100 | ``` 101 | 102 | If all of the following conditions meet, you need to override `getResources()` function also in your `Activity` 103 | 1. App's `minSdkVersion` is < 17 104 | 2. You have a dependency on `androidx.appcompat:appcompat:1.2.0` 105 | 3. Your Activity extends from [`AppCompatActivity`](https://developer.android.com/reference/androidx/appcompat/app/AppCompatActivity) 106 | 107 | ```java 108 | // Java 109 | 110 | private @Nullable StringPackResources stringPackResources; 111 | @Override 112 | public Resources getResources() { 113 | if (stringPackResources == null) { 114 | stringPackResources = StringPackResources.wrap(super.getResources()); 115 | } 116 | return stringPackResources; 117 | } 118 | ``` 119 | 120 | ```kotlin 121 | // Kotlin 122 | 123 | private @Nullable var stringPackResources:Resources? = null 124 | override fun getResources(): Resources? { 125 | if (stringPackResources == null) { 126 | stringPackResources = StringPackResources.wrap(super.getResources()) 127 | } 128 | return stringPackResources 129 | } 130 | ``` 131 | 132 | Your Android application also needs to use a custom [`Application`](https://developer.android.com/reference/android/app/Application), which needs to include the following code to ensure the strings are read from `.pack` files. 133 | 134 | ```java 135 | // Java 136 | 137 | @Override 138 | protected void attachBaseContext(Context base) { 139 | StringPackIds.registerStringPackIds(); 140 | StringPacks.getInstance().setUp(base); 141 | 142 | super.attachBaseContext(base); 143 | } 144 | 145 | private @Nullable StringPackResources stringPackResources; 146 | @Override 147 | public Resources getResources() { 148 | if (stringPackResources == null) { 149 | stringPackResources = StringPackResources.wrap(super.getResources()); 150 | } 151 | return stringPackResources; 152 | } 153 | ``` 154 | 155 | ```kotlin 156 | // Kotlin 157 | 158 | override fun attachBaseContext(base: Context?) { 159 | registerStringPackIds() 160 | StringPacks.getInstance().setUp(base) 161 | 162 | super.attachBaseContext(base) 163 | } 164 | 165 | private @Nullable var stringPackResources:Resources? = null 166 | override fun getResources(): Resources? { 167 | if (stringPackResources == null) { 168 | stringPackResources = StringPackResources.wrap(super.getResources()) 169 | } 170 | return stringPackResources 171 | } 172 | ``` 173 | 174 | You only need to do this each time you add a new context component. You don't need to do this for each component if you add them to a base class. 175 | 176 | ### Region specific locales & Fallback 177 | 178 | You can map multiple regions into a single `.pack` file using `pack_id_mapping` in [config.json](library/templates/config.json). For example 179 | 180 | ```json 181 | pack_id_mapping = { 182 | "es-rMX": "es", 183 | "es-rES": "es" 184 | } 185 | ``` 186 | 187 | Here, translations in `"es"`, `"es-MX"` and `"es-ES"` locales would be packed into `strings_es.pack` file. 188 | 189 | If you are supporting any of the following features, you need to implement [StringPacksLocaleMetaDataProvider.java](library/src/main/java/com/whatsapp/stringpacks/StringPacksLocaleMetaDataProvider.java) and register the provider in your custom `Application` class 190 | 1. Packing translations for multiple locales (for example, `es-MX`, `es`) in to one `.pack` file, or 191 | 2. Fallback feature, or 192 | 3. Supporting region specific locales 193 | 194 | ```java 195 | // Java 196 | 197 | @Nullable private final StringPacksLocaleMetaDataProvider metaData = new LocaleMetaDataProviderImpl(); 198 | @Override 199 | protected void attachBaseContext(Context base) { 200 | StringPackIds.registerStringPackIds(); 201 | StringPacks.registerStringPackLocaleMetaData(metaData); 202 | StringPacks.getInstance().setUp(base); 203 | 204 | super.attachBaseContext(base); 205 | } 206 | ``` 207 | 208 | ```kotlin 209 | // Kotlin 210 | 211 | private @Nullable var metaData:StringPacksLocaleMetaDataProvider? = LocaleMetaDataProviderImpl() 212 | override fun attachBaseContext(base: Context?) { 213 | registerStringPackIds(); 214 | StringPacks.registerStringPackLocaleMetaData(metaData); 215 | StringPacks.getInstance().setUp(base); 216 | 217 | super.attachBaseContext(base); 218 | } 219 | ``` 220 | 221 | Take a look at [LocaleMetaDataProviderImpl.java](sample/app/src/main/java/com/whatsapp/stringpacks/sample/LocaleMetaDataProviderImpl.java) in the sample app for reference. 222 | 223 | ### Generate `.pack` files 224 | 225 | You have added the `StringPackIds` file to your project, but it has nothing in it yet. It is supposed to hold the mapping from android resource IDs (`R.string`) to string pack IDs. 226 | The content would be automatically filled in when you run the script that provided by this library. 227 | The mapping information would also be used for generating the `.pack` files, so they are correctly loaded at runtime. 228 | 229 | Execute the python script from your project root directory to assemble the string packs: 230 | ```bash 231 | python3 ./scripts/assemble_string_packs.py --config ./{path_to}/config.json 232 | ``` 233 | 234 | You will see: 235 | 236 | - The `StringPackIds` file has been updated with the pack ID mapping information; 237 | - The translation strings, which are packable, have been moved to different directory, so that they won't be compiled into the APK; 238 | - The `.pack` file for different language have been generated under the project assets/ directory. 239 | 240 | When you update translations, or change a string in the project, you may run the script again to generate `.pack` files with latest content. 241 | 242 | Those string resource IDs that are not listed in the `StringPackIds` file, will continue to be kept in the Android system resources, and the StringPacks runtime would automatically fall back to read from there. 243 | 244 |   245 | 246 | Now, you can use gradle to build your application as usual. The application should correctly retrieve the strings from StringPacks. 247 | 248 | ## License 249 | ``` 250 | Copyright (c) Facebook, Inc. and its affiliates. 251 | 252 | Licensed under the Apache License, Version 2.0 (the "License"); 253 | you may not use this file except in compliance with the License. 254 | You may obtain a copy of the License at 255 | 256 | http://www.apache.org/licenses/LICENSE-2.0 257 | 258 | Unless required by applicable law or agreed to in writing, software 259 | distributed under the License is distributed on an "AS IS" BASIS, 260 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 261 | See the License for the specific language governing permissions and 262 | limitations under the License. 263 | ``` 264 | -------------------------------------------------------------------------------- /library/android-library-config.gradle: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 2 | * 3 | * This source code is licensed under the Apache 2.0 license found in 4 | * the LICENSE file in the root directory of this source tree. 5 | */ 6 | 7 | apply plugin: 'com.android.library' 8 | 9 | android { 10 | compileSdkVersion 30 11 | 12 | defaultConfig { 13 | minSdkVersion 15 14 | targetSdkVersion 29 15 | } 16 | 17 | compileOptions { 18 | sourceCompatibility JavaVersion.VERSION_1_8 19 | targetCompatibility JavaVersion.VERSION_1_8 20 | } 21 | 22 | testOptions { 23 | unitTests { 24 | returnDefaultValues = true 25 | includeAndroidResources = true 26 | } 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /library/gradle.properties: -------------------------------------------------------------------------------- 1 | # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 2 | # 3 | # This source code is licensed under the Apache 2.0 license found in 4 | # the LICENSE file in the root directory of this source tree. 5 | # 6 | 7 | # StringPacks Library 8 | android.useAndroidX=true 9 | 10 | ## Maven specific properties 11 | GROUP=com.whatsapp.stringpacks 12 | LIBRARY_VERSION_NAME=0.3.1 13 | 14 | # Maven artifact information 15 | POM_ARTIFACT_ID=stringpacks 16 | POM_NAME=StringPacks 17 | POM_PACKAGING=aar 18 | POM_DESCRIPTION=StringPacks Efficient Storage of Localized strings in an Android app 19 | POM_URL=https://github.com/WhatsApp/StringPacks 20 | POM_SCM_URL=https://github.com/WhatsApp/StringPacks.git 21 | POM_SCM_CONNECTION=scm:git:https://github.com/WhatsApp/StringPacks.git 22 | POM_SCM_DEV_CONNECTION=scm:git:git@github.com:WhatsApp/StringPacks.git 23 | POM_LICENSE_NAME=Apache License 2.0 24 | POM_LICENSE_URL=https://github.com/WhatsApp/StringPacks/blob/main/LICENSE 25 | POM_LICENSE_DIST=repo 26 | POM_DEVELOPER_ID=whatsapp 27 | POM_DEVELOPER_NAME=WhatsApp 28 | -------------------------------------------------------------------------------- /library/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /library/pack.gradle: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 2 | * 3 | * This source code is licensed under the Apache 2.0 license found in 4 | * the LICENSE file in the root directory of this source tree. 5 | */ 6 | 7 | import groovy.json.JsonSlurper 8 | 9 | // Register task to generate string pack files. 10 | if (hasProperty('stringPacksConfigFile')) { 11 | def configFile = file(property('stringPacksConfigFile')) 12 | def spConfig = new JsonSlurper().parse(configFile) 13 | 14 | def packScript = "$rootDir/${spConfig.pack_scripts_directory}/pack_strings.py" 15 | 16 | def assetsDir = "$rootDir/${spConfig.assets_directory}" 17 | 18 | tasks.register('generateStringPacks', Exec) { 19 | workingDir "$rootDir" 20 | 21 | commandLine 'python3', packScript, '--config', configFile.path 22 | } 23 | 24 | tasks.whenTaskAdded { task -> 25 | if (task.name =~ /^generate.*Assets$/) { 26 | task.dependsOn generateStringPacks 27 | } 28 | } 29 | 30 | clean.doFirst { 31 | delete fileTree(assetsDir).include("strings_*.pack") 32 | } 33 | } -------------------------------------------------------------------------------- /library/release.gradle: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 2 | * 3 | * This source code is licensed under the Apache 2.0 license found in 4 | * the LICENSE file in the root directory of this source tree. 5 | */ 6 | 7 | apply plugin: 'maven-publish' 8 | apply plugin: 'signing' 9 | 10 | version = LIBRARY_VERSION_NAME 11 | group = GROUP 12 | 13 | def isReleaseBuild() { 14 | return LIBRARY_VERSION_NAME.contains("SNAPSHOT") == false 15 | } 16 | 17 | def getMavenRepositoryUrl() { 18 | return hasProperty('repositoryUrl') ? property('repositoryUrl') : "https://oss.sonatype.org/service/local/staging/deploy/maven2/" 19 | } 20 | 21 | def getMavenRepositoryUsername() { 22 | return hasProperty('repositoryUsername') ? property('repositoryUsername') : "" 23 | } 24 | 25 | def getMavenRepositoryPassword() { 26 | return hasProperty('repositoryPassword') ? property('repositoryPassword') : "" 27 | } 28 | 29 | afterEvaluate { project -> 30 | task androidJavadoc(type: Javadoc) { 31 | source = android.sourceSets.main.java.source 32 | classpath += files(android.getBootClasspath().join(File.pathSeparator)) 33 | if (JavaVersion.current().isJava8Compatible()) { 34 | options.addStringOption('Xdoclint:none', '-quiet') 35 | } 36 | if (JavaVersion.current().isJava9Compatible()) { 37 | options.addBooleanOption('html5', true) 38 | } 39 | } 40 | 41 | task androidJavadocJar(type: Jar, dependsOn: androidJavadoc) { 42 | classifier = 'javadoc' 43 | from androidJavadoc.destinationDir 44 | } 45 | 46 | task androidSourcesJar(type: Jar) { 47 | classifier = 'sources' 48 | from android.sourceSets.main.java.source 49 | } 50 | 51 | artifacts { 52 | archives androidSourcesJar 53 | archives androidJavadocJar 54 | } 55 | 56 | android.libraryVariants.all { variant -> 57 | tasks.androidJavadoc.doFirst { 58 | classpath += files(variant.javaCompileProvider.get().classpath.files.join(File.pathSeparator)) 59 | } 60 | def name = variant.name.capitalize() 61 | task "jar${name}"(type: Jar, dependsOn: variant.javaCompileProvider) { 62 | from variant.javaCompileProvider.get().destinationDir 63 | } 64 | } 65 | 66 | def releaseVariant 67 | android.libraryVariants.all { variant -> 68 | if (variant.buildType.name == 'release') { 69 | releaseVariant = variant.name 70 | } 71 | } 72 | 73 | publishing { 74 | publications { 75 | mavenRelease(MavenPublication) { 76 | groupId GROUP 77 | artifactId POM_ARTIFACT_ID 78 | version LIBRARY_VERSION_NAME 79 | 80 | from components[releaseVariant] 81 | 82 | artifact androidJavadocJar 83 | artifact androidSourcesJar 84 | 85 | pom { 86 | name = POM_NAME 87 | description = POM_DESCRIPTION 88 | url = POM_URL 89 | 90 | scm { 91 | url = POM_SCM_URL 92 | connection = POM_SCM_CONNECTION 93 | developerConnection = POM_SCM_DEV_CONNECTION 94 | } 95 | 96 | licenses { 97 | license { 98 | name = POM_LICENSE_NAME 99 | url = POM_LICENSE_URL 100 | distribution = POM_LICENSE_DIST 101 | } 102 | } 103 | 104 | developers { 105 | developer { 106 | id = POM_DEVELOPER_ID 107 | name = POM_DEVELOPER_NAME 108 | } 109 | } 110 | } 111 | } 112 | } 113 | 114 | repositories { 115 | maven { 116 | url getMavenRepositoryUrl() 117 | credentials(PasswordCredentials) { 118 | username = getMavenRepositoryUsername() 119 | password = getMavenRepositoryPassword() 120 | } 121 | } 122 | } 123 | } 124 | 125 | signing { 126 | required { isReleaseBuild() } 127 | publishing.publications.all { publication -> 128 | sign publication 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /library/scripts/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /library/scripts/assemble_string_packs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 4 | # 5 | # This source code is licensed under the Apache 2.0 license found in 6 | # the LICENSE file in the root directory of this source tree. 7 | 8 | 9 | """This is an integrated script that runs all three StringPacks process steps (find/move/pack) at the same time.""" 10 | 11 | import argparse 12 | import logging 13 | 14 | import find_movable_strings 15 | import move_strings_for_packing 16 | import pack_strings 17 | import string_pack_config 18 | 19 | 20 | def create_arg_parser(): 21 | arg_parser = argparse.ArgumentParser(description="Assemble String Packs.") 22 | arg_parser.add_argument( 23 | "--config", help="Location of JSON config file.", required=True 24 | ) 25 | 26 | return arg_parser 27 | 28 | 29 | def main(): 30 | logging.basicConfig(level=logging.INFO) 31 | 32 | arg_parser = create_arg_parser() 33 | 34 | args = arg_parser.parse_args() 35 | 36 | sp_config = string_pack_config.load_config(config_json_file_path=args.config) 37 | 38 | find_movable_strings.find_movable_strings(sp_config, print_reverse=False) 39 | move_strings_for_packing.move_all_strings(sp_config, keep_dest=True) 40 | pack_strings.pack_strings(sp_config, pack_strings.noop_plural_handler) 41 | 42 | 43 | if __name__ == "__main__": 44 | main() 45 | -------------------------------------------------------------------------------- /library/scripts/find_movable_strings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 4 | # 5 | # This source code is licensed under the Apache 2.0 license found in 6 | # the LICENSE file in the root directory of this source tree. 7 | 8 | 9 | import argparse 10 | import logging 11 | import math 12 | import re 13 | import subprocess 14 | from os import path 15 | from typing import Dict, List, Set, Tuple 16 | from xml.etree import ElementTree 17 | 18 | import string_pack_config 19 | from move_strings_for_packing import get_resource_content_with_resources_header 20 | 21 | 22 | # Escape code for color 23 | SET_WARNING_COLOR = "\033[33m\033[41m" # yellow text with red background 24 | CLEAR_COLOR = "\033[0m" 25 | 26 | NAMESPACE_AND_ATTRIB_RE = re.compile("^\{(.+)\}(.+)$") 27 | 28 | 29 | def separate_namespace(attribute_name): 30 | match = NAMESPACE_AND_ATTRIB_RE.match(attribute_name) 31 | if match: 32 | return match.groups() 33 | else: 34 | return None, attribute_name 35 | 36 | 37 | STRING_USAGE_RE = re.compile("@string/([A-Za-z0-9_]+)") 38 | 39 | OK_NAMESPACES = {"http://schemas.android.com/tools"} 40 | 41 | # Previously, we would just generate a list of string ids and their mapping, one pair per line 42 | # broken with newlines. 43 | # Unfortunately, this breaks if there are around 8k strings or so as it results in the method's 44 | # bytecode size exceeding the JVM limit (64k). 45 | # Thus, we must instead break the list into parts if we hit this limit. 46 | # But then we can't just generate a list with newlines - we also need to generate the code 47 | # surrounding the list pieces so the developer can still easily use one statement 48 | # (getStringPacksMapping()) to use the generated code. 49 | MAX_IDS_PER_METHOD = 8000 50 | 51 | DONOTPACK_RE = re.compile('<(?:string|plurals) name="([^"]+)".*donotpack="true"') 52 | 53 | 54 | def find_donotpack_strings(filename): 55 | result = set() 56 | header, data = get_resource_content_with_resources_header(filename) 57 | for match in DONOTPACK_RE.finditer(data): 58 | result.add(match.group(1)) 59 | return result 60 | 61 | 62 | def find_strings_used_in_xml(filename, safe_widgets): 63 | result = set() 64 | # Ignore the file if it throws an error while parsing 65 | # Started seeing this in some of the xml files, that were not layout files. We do not expect 66 | # this error to be thrown in layout files or files that we are interested in 67 | try: 68 | tree = ElementTree.parse(filename) 69 | for node in tree.findall(".//"): 70 | if node.tag in safe_widgets: 71 | continue # Certain widgets can handle @string just fine 72 | if node.text is not None: 73 | for string in STRING_USAGE_RE.findall(node.text): 74 | result.add(string) 75 | for key, value in node.attrib.items(): 76 | string_usage_match = STRING_USAGE_RE.search(value) 77 | if string_usage_match: 78 | namespace, attrib = separate_namespace(key) 79 | if namespace in OK_NAMESPACES: 80 | continue # Certain namespace are safe to use @string in 81 | result.add(string_usage_match.group(1)) 82 | except ElementTree.ParseError: 83 | logging.warning( 84 | SET_WARNING_COLOR 85 | + "Dropping the file becase of ParseError: " 86 | + filename 87 | + CLEAR_COLOR 88 | ) 89 | finally: 90 | return result 91 | 92 | 93 | NAME_CATCHER_RE = re.compile('<(string|plurals) name="([^"]+)"') 94 | 95 | 96 | def output_string_ids_setting(sp_config, strings_to_move: Set): 97 | class_file_path = sp_config.pack_ids_class_file_path 98 | resource_config_setting = sp_config.resource_config_setting 99 | sorted_strings_to_move = sorted(strings_to_move) 100 | if class_file_path is None and resource_config_setting is None: 101 | print( 102 | "Invalid config. Either class_file_path or resource_config_path needs to be set" 103 | ) 104 | return 105 | if class_file_path is not None: 106 | output_string_ids_map(class_file_path, sorted_strings_to_move) 107 | else: 108 | output_string_ids_config(resource_config_setting, sorted_strings_to_move) 109 | 110 | 111 | def _generate_java_source_line(content: str, value: int): 112 | return content + hex(value) + ";\n" 113 | 114 | 115 | def output_string_ids_config( 116 | resource_config_setting: Dict, sorted_strings_to_move: List[Tuple] 117 | ): 118 | output_config_file = resource_config_setting["config_file_path"] 119 | output_source_file = resource_config_setting["source_file_path"] 120 | resource_id_offset = { 121 | "string": int(resource_config_setting["string_offset"], 16), 122 | "plurals": int(resource_config_setting["plurals_offset"], 16), 123 | } 124 | package_name = resource_config_setting["package_name"] 125 | 126 | string_pack_ids = [] 127 | index = {"string": 0, "plurals": 0} 128 | for string_tuple in sorted_strings_to_move: 129 | string_type, string_name = string_tuple 130 | string_id = hex(resource_id_offset[string_type] + index[string_type]) 131 | string_pack_ids.append( 132 | f"{package_name}:{string_type}/{string_name} = {string_id}" 133 | ) 134 | index[string_type] = index[string_type] + 1 135 | if not path.exists(output_source_file): 136 | # No class file is provided, print to console directly to let people copy/paste later. 137 | for pack_id in string_pack_ids: 138 | print(pack_id) 139 | return 140 | with open(output_config_file, "wt") as pack_ids_file: 141 | pack_ids_file.writelines("\n".join(string_pack_ids)) 142 | assert output_source_file.endswith(".java"), "We only support Java for now." 143 | with open(output_source_file, "rt") as pack_file: 144 | source_file_lines = pack_file.readlines() 145 | 146 | region_start_index = None 147 | region_end_index = None 148 | for i, line in enumerate(source_file_lines): 149 | if "// region StringPacks ID range" in line: 150 | region_start_index = i 151 | elif "// endregion" in line: 152 | region_end_index = i 153 | 154 | if region_start_index is None or region_end_index is None: 155 | print( 156 | f"Can't find the String Pack IDs map region in {output_source_file} to update content." 157 | ) 158 | return 159 | 160 | STRING_BEGIN = "private static final int STRING_BEGIN = " 161 | STRING_END = "private static final int STRING_END = " 162 | PLURALS_BEGIN = "private static final int PLURALS_BEGIN = " 163 | PLURALS_END = "private static final int PLURALS_END = " 164 | leading_space_num = source_file_lines[region_start_index].index("//") 165 | leading_space = " " * leading_space_num 166 | output_source_file_lines = source_file_lines[0 : region_start_index + 1] 167 | output_source_file_lines += _generate_java_source_line( 168 | leading_space + STRING_BEGIN, resource_id_offset["string"] 169 | ) 170 | output_source_file_lines += _generate_java_source_line( 171 | leading_space + STRING_END, resource_id_offset["string"] + index["string"] - 1 172 | ) 173 | output_source_file_lines += _generate_java_source_line( 174 | leading_space + PLURALS_BEGIN, resource_id_offset["plurals"] 175 | ) 176 | output_source_file_lines += _generate_java_source_line( 177 | leading_space + PLURALS_END, 178 | resource_id_offset["plurals"] + index["plurals"] - 1, 179 | ) 180 | output_source_file_lines += source_file_lines[region_end_index:] 181 | 182 | with open(output_source_file, "wt") as pack_file: 183 | pack_file.writelines("".join(output_source_file_lines)) 184 | 185 | 186 | def output_string_ids_map(class_file_path: str, sorted_strings_to_move: List[Tuple]): 187 | string_pack_ids = [] 188 | for string_tuple in sorted_strings_to_move: 189 | string_type, string_name = string_tuple 190 | string_pack_ids.append((" " * 10 + "R.%s.%s,") % (string_type, string_name)) 191 | 192 | if not path.exists(class_file_path): 193 | # No class file is provided, print to console directly to let people copy/paste later. 194 | for pack_id in string_pack_ids: 195 | print(pack_id) 196 | return 197 | 198 | # Directly update the class file with latest ids. 199 | with open(class_file_path, "rt") as pack_ids_file: 200 | existing_class_file_lines = pack_ids_file.readlines() 201 | 202 | region_start_index = None 203 | region_end_index = None 204 | for i, line in enumerate(existing_class_file_lines): 205 | if "// region" in line: 206 | region_start_index = i 207 | elif "// endregion" in line: 208 | region_end_index = i 209 | 210 | if region_start_index is None or region_end_index is None: 211 | print( 212 | "Can't find the String Pack IDs map region in %s to update content." 213 | % class_file_path 214 | ) 215 | return 216 | 217 | with open(class_file_path, "wt") as pack_ids_file: 218 | pack_ids_file.writelines( 219 | existing_class_file_lines[0 : region_start_index + 1] 220 | + ( 221 | generate_kotlin(string_pack_ids) 222 | if class_file_path.endswith(".kt") 223 | else generate_java(string_pack_ids) 224 | ) 225 | + existing_class_file_lines[region_end_index:] 226 | ) 227 | print("Updated: " + class_file_path) 228 | 229 | 230 | def generate_java(string_pack_ids): 231 | if len(string_pack_ids) <= MAX_IDS_PER_METHOD: 232 | # No need for sub-methods, just create the whole array in the main method 233 | return generate_java_internal(string_pack_ids, "getStringPacksMapping") 234 | 235 | result = [] 236 | result += " " * 2 + "private static int[] getStringPacksMapping() {\n" 237 | result += ( 238 | " " * 6 + "final int[] result = new int[" + str(len(string_pack_ids)) + "];\n" 239 | ) 240 | result += " " * 6 + "int[] part;\n" 241 | 242 | parts = math.ceil(len(string_pack_ids) / MAX_IDS_PER_METHOD) 243 | for i in range(0, parts): 244 | result += " " * 6 + "part = getStringPacksMappingPart" + str(i) + "();\n" 245 | result += ( 246 | " " * 6 247 | + "System.arraycopy(part, 0, result, " 248 | + str(MAX_IDS_PER_METHOD * i) 249 | + ", part.length);\n" 250 | ) 251 | 252 | result += " " * 6 + "return result;\n" 253 | result += " " * 2 + "}\n" 254 | result += "\n" 255 | 256 | # Create submethods 257 | for i in range(0, parts): 258 | start = i * MAX_IDS_PER_METHOD 259 | end = start + MAX_IDS_PER_METHOD 260 | result += generate_java_internal( 261 | string_pack_ids[start:end], "getStringPacksMappingPart%s" % i 262 | ) 263 | 264 | return result 265 | 266 | 267 | def generate_java_internal(string_pack_ids, method_name): 268 | result = [] 269 | result += " " * 2 + "private static int[] %s() {\n" % method_name 270 | result += " " * 6 + "return new int[]{\n" 271 | for line in string_pack_ids: 272 | result += line + "\n" 273 | result += " " * 6 + "};\n" 274 | result += " " * 2 + "}\n" 275 | result += "\n" 276 | return result 277 | 278 | 279 | def generate_kotlin(string_pack_ids): 280 | if len(string_pack_ids) <= MAX_IDS_PER_METHOD: 281 | # No need for sub-methods, just create the whole array in the main method 282 | return generate_kotlin_internal(string_pack_ids, "getStringPacksMapping") 283 | 284 | result = [] 285 | result += "fun getStringPacksMapping(): Array {\n" 286 | result += " " * 4 + "val result = int[" + str(len(string_pack_ids)) + "]\n" 287 | result += " " * 4 + "var part: Array\n" 288 | 289 | parts = math.ceil(len(string_pack_ids) / MAX_IDS_PER_METHOD) 290 | for i in range(0, parts): 291 | result += " " * 4 + "part = getStringPacksMappingPart" + str(i) + "()\n" 292 | result += ( 293 | " " * 4 294 | + "System.arraycopy(part, 0, result, " 295 | + str(MAX_IDS_PER_METHOD * i) 296 | + ", part.length)\n" 297 | ) 298 | 299 | result += " " * 4 + "result\n" 300 | result += "}\n" 301 | result += "\n" 302 | 303 | # Create submethods 304 | for i in range(0, parts): 305 | start = i * MAX_IDS_PER_METHOD 306 | end = start + MAX_IDS_PER_METHOD 307 | result += generate_kotlin_internal( 308 | string_pack_ids[start:end], "getStringPacksMappingPart%s" % i 309 | ) 310 | 311 | return result 312 | 313 | 314 | def generate_kotlin_internal(string_pack_ids, method_name): 315 | result = [] 316 | result += "fun %s(): Array {\n" % method_name 317 | result += " " * 4 + "intArrayOf(\n" 318 | for line in string_pack_ids: 319 | result += line + "\n" 320 | result += " " * 4 + ")\n" 321 | result += "}\n" 322 | result += "\n" 323 | return result 324 | 325 | 326 | def generate_non_movable_set(sp_config, xml_files) -> Set: 327 | not_movable = set() 328 | for filename in xml_files: 329 | if not filename: 330 | continue 331 | if filename.endswith("/values/strings.xml"): 332 | not_movable.update(find_donotpack_strings(filename)) 333 | continue 334 | if filename.endswith("/strings.xml"): 335 | continue 336 | not_movable.update( 337 | find_strings_used_in_xml(filename, sp_config.safe_widget_classes) 338 | ) 339 | return not_movable 340 | 341 | 342 | def find_movable_strings(sp_config, print_reverse=False): 343 | xml_files = subprocess.check_output( 344 | sp_config.find_resource_files_command, shell=True, encoding="ASCII" 345 | ).split("\n") 346 | 347 | not_movable = generate_non_movable_set(sp_config, xml_files) 348 | 349 | strings_to_move = set() 350 | for source_file in sp_config.get_default_string_files(): 351 | with open(source_file) as english_sources: 352 | for match in NAME_CATCHER_RE.findall(english_sources.read()): 353 | msg_name = match[1] 354 | if msg_name in not_movable: 355 | if print_reverse: 356 | print(msg_name) 357 | continue 358 | strings_to_move.add(match) 359 | 360 | # Don't output IDs if we are interested in the unmovable strings. 361 | if not print_reverse: 362 | output_string_ids_setting(sp_config, strings_to_move) 363 | 364 | 365 | def main(): 366 | parser = argparse.ArgumentParser(description="Find strings to move to string packs") 367 | parser.add_argument( 368 | "--reverse", 369 | action="store_true", 370 | help="List the strings that cannot be moved, instead of those that can be moved.", 371 | ) 372 | 373 | parser.add_argument("--config", help="Location of JSON config file.") 374 | args = parser.parse_args() 375 | 376 | sp_config = string_pack_config.load_config(config_json_file_path=args.config) 377 | find_movable_strings(sp_config, args.reverse) 378 | 379 | 380 | if __name__ == "__main__": 381 | main() 382 | -------------------------------------------------------------------------------- /library/scripts/id_finder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 4 | # 5 | # This source code is licensed under the Apache 2.0 license found in 6 | # the LICENSE file in the root directory of this source tree. 7 | 8 | import os 9 | import re 10 | from typing import List, Optional 11 | 12 | from string_pack_config import StringPackConfig 13 | 14 | 15 | class IdFinder(object): 16 | def __init__(self, all_matches: List): 17 | self.seen_ids = {} 18 | for i in range(len(all_matches)): 19 | self.seen_ids[all_matches[i]] = i 20 | 21 | @classmethod 22 | def from_resource_config(cls, config_file_path: str) -> "IdFinder": 23 | assert os.path.exists( 24 | config_file_path 25 | ), f"Config file {config_file_path} does not exist" 26 | all_matches = [] 27 | with open(config_file_path, "rt") as fd: 28 | id_data = fd.read() 29 | all_matches = re.findall( 30 | r"\:(?:string|plurals)\/(\w+) =", id_data, flags=re.DOTALL 31 | ) 32 | return cls(all_matches) 33 | 34 | @classmethod 35 | def from_stringpack_config(cls, sp_config: StringPackConfig) -> "IdFinder": 36 | if sp_config.pack_ids_class_file_path is not None: 37 | with open(sp_config.pack_ids_class_file_path, "rt") as fd: 38 | id_data = fd.read() 39 | all_matches = re.findall( 40 | r"R\.(?:string|plurals)(?:.*?)\.(\w+),", id_data, flags=re.DOTALL 41 | ) 42 | return cls(all_matches) 43 | else: 44 | return cls.from_resource_config( 45 | sp_config.resource_config_setting["config_file_path"] 46 | ) 47 | 48 | def get_id(self, resource_name: str) -> Optional[int]: 49 | return self.seen_ids.get(resource_name) 50 | -------------------------------------------------------------------------------- /library/scripts/move_strings_for_packing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 4 | # 5 | # This source code is licensed under the Apache 2.0 license found in 6 | # the LICENSE file in the root directory of this source tree. 7 | 8 | 9 | import argparse 10 | import glob 11 | import logging 12 | import os 13 | import re 14 | 15 | import string_pack_config 16 | 17 | from id_finder import IdFinder 18 | from string_pack_config import LanguageHandlingCase 19 | 20 | 21 | HEADER = """ 22 | 23 | """ 24 | 25 | RESOURCES_HEADER = "\n" 26 | FOOTER = "\n" 27 | 28 | # fmt: off 29 | ROW_PATTERN = re.compile( 30 | "(?:[ \t]*\n)?" 31 | "[ \t]*<(string|plurals) name=\"(.+?)\">" 32 | ".*?" 33 | "\n", 34 | re.DOTALL, 35 | ) 36 | # fmt: on 37 | 38 | 39 | def get_resource_content_with_resources_header(resource_file_path): 40 | with open(resource_file_path, "rt") as resource_file: 41 | # skip headers 42 | resources_header = None 43 | for line in resource_file: 44 | if " tag and its contents as is in original strings.xml file 82 | xml_file.write(src_resources_header) 83 | xml_file.write("".join(output_for_original_file)) 84 | xml_file.write(FOOTER) 85 | 86 | if not output_for_new_file: 87 | logging.debug("No strings to write out") 88 | 89 | # There's no strings to write 90 | return 91 | 92 | # Create a directory for dstfile, in case it doesn't exist 93 | os.makedirs(os.path.dirname(dstfile), exist_ok=True) 94 | 95 | with open(dstfile, "wt") as xml_file: 96 | logging.debug("Writing out: %s", dstfile) 97 | xml_file.write(HEADER) 98 | # Using the basic tag in stringpacks strings.xml file 99 | xml_file.write(RESOURCES_HEADER) 100 | xml_file.write("".join(output_for_new_file)) 101 | xml_file.write(FOOTER) 102 | 103 | 104 | def get_dest_file( 105 | moved_resource_directory, language_qualifier, destination_stringpack_directories_map 106 | ): 107 | if moved_resource_directory in destination_stringpack_directories_map: 108 | stringpack_root_dir = destination_stringpack_directories_map[ 109 | moved_resource_directory 110 | ] 111 | else: 112 | stringpack_root_dir = moved_resource_directory 113 | dest_file = os.path.join( 114 | stringpack_root_dir, 115 | "string-packs", 116 | "strings", 117 | "values-" + language_qualifier, 118 | "strings.xml", 119 | ) 120 | logging.debug("To file: %s", dest_file) 121 | return dest_file 122 | 123 | 124 | # Escape code for color 125 | SET_WARNING_COLOR = "\033[33m\033[41m" # yellow text with red background 126 | CLEAR_COLOR = "\033[0m" 127 | 128 | 129 | def move_all_strings(sp_config, keep_dest): 130 | id_finder = IdFinder.from_stringpack_config(sp_config) 131 | for resources_directory in sp_config.original_resources_directories: 132 | path_pattern = os.path.join( 133 | resources_directory, "res", "values-*", "strings.xml" 134 | ) 135 | 136 | for string_xml_path in glob.glob(path_pattern): 137 | values_directory_name = os.path.normpath(string_xml_path).split(os.sep)[-2] 138 | resource_qualifier = values_directory_name.replace("values-", "") 139 | 140 | handler_case = sp_config.get_handling_case(resource_qualifier) 141 | if handler_case == LanguageHandlingCase.DROP: 142 | logging.warning( 143 | SET_WARNING_COLOR + "Dropping: " + string_xml_path + CLEAR_COLOR 144 | ) 145 | move_strings(string_xml_path, os.devnull, id_finder, keep_dest=False) 146 | elif handler_case == LanguageHandlingCase.PACK: 147 | logging.debug("Moving: %s", string_xml_path) 148 | move_strings( 149 | string_xml_path, 150 | get_dest_file( 151 | resources_directory, 152 | resource_qualifier, 153 | sp_config.destination_stringpack_directories, 154 | ), 155 | id_finder, 156 | keep_dest, 157 | ) 158 | 159 | elif handler_case == LanguageHandlingCase.KEEP_ORIGINAL: 160 | logging.info("Keep untouched: %s", string_xml_path) 161 | 162 | 163 | def main(): 164 | logging.basicConfig(level=logging.INFO) 165 | arg_parser = argparse.ArgumentParser() 166 | arg_parser.add_argument("--config", help="Location of JSON config file.") 167 | arg_parser.add_argument("--keep-dest", action="store_true") 168 | args = arg_parser.parse_args() 169 | 170 | sp_config = string_pack_config.load_config(config_json_file_path=args.config) 171 | move_all_strings(sp_config, args.keep_dest) 172 | 173 | 174 | if __name__ == "__main__": 175 | main() 176 | -------------------------------------------------------------------------------- /library/scripts/pack_strings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 4 | # 5 | # This source code is licensed under the Apache 2.0 license found in 6 | # the LICENSE file in the root directory of this source tree. 7 | 8 | import argparse 9 | import collections 10 | import glob 11 | import logging 12 | import multiprocessing 13 | import os 14 | 15 | import string_pack 16 | import string_pack_config 17 | from id_finder import IdFinder 18 | from string_pack_config import LanguageHandlingCase, StringPackConfig 19 | 20 | 21 | def group_string_files_by_languages( 22 | sp_config: StringPackConfig, packable_strings_file_paths 23 | ): 24 | # A map from language (aka, pack ID) to list of string resource files. 25 | grouped_files = collections.defaultdict(list) 26 | 27 | for strings_file in packable_strings_file_paths: 28 | values_dir_name = os.path.normpath(strings_file).split(os.sep)[-2] 29 | language = values_dir_name.replace("values-", "") 30 | handler_case = sp_config.get_handling_case(language) 31 | if handler_case == LanguageHandlingCase.PACK: 32 | pack_id = sp_config.pack_id_mapping.get(language, language) 33 | grouped_files[pack_id].append(strings_file) 34 | 35 | return grouped_files 36 | 37 | 38 | def get_dest_pack_file_path(sp_config: StringPackConfig, pack_id): 39 | prefix = "" if sp_config.module is None else (sp_config.module + "_") 40 | return os.path.join(sp_config.assets_directory, f"{prefix}strings_{pack_id}.pack") 41 | 42 | 43 | def pack_strings(sp_config: StringPackConfig, plural_handler): 44 | id_finder = IdFinder.from_stringpack_config(sp_config) 45 | packable_strings_file_paths = [] 46 | 47 | moved = [] 48 | for directory in sp_config.original_resources_directories: 49 | if directory in sp_config.destination_stringpack_directories: 50 | root_dir = sp_config.destination_stringpack_directories[directory] 51 | else: 52 | root_dir = directory 53 | moved.append(os.path.join(root_dir, "string-packs", "strings")) 54 | 55 | for directory in moved: 56 | string_files = sorted( 57 | glob.glob(os.path.join(directory, "**/strings.xml"), recursive=True) 58 | ) 59 | new_string_files = [ 60 | file for file in string_files if file not in packable_strings_file_paths 61 | ] 62 | packable_strings_file_paths.extend(new_string_files) 63 | 64 | grouped_strings_file_paths = group_string_files_by_languages( 65 | sp_config, packable_strings_file_paths 66 | ) 67 | 68 | # Create assets directory in case it does not exist. 69 | os.makedirs(sp_config.assets_directory, exist_ok=True) 70 | 71 | PackBuilder( 72 | sp_config, grouped_strings_file_paths, id_finder, plural_handler 73 | ).build() 74 | 75 | 76 | class PackBuilder(object): 77 | def __init__( 78 | self, 79 | sp_config: StringPackConfig, 80 | grouped_strings_file_paths, 81 | id_finder, 82 | plural_handler, 83 | ): 84 | self.sp_config = sp_config 85 | self.grouped_strings_file_paths = grouped_strings_file_paths 86 | self.id_finder = id_finder 87 | self.plural_handler = plural_handler 88 | 89 | def build(self): 90 | with multiprocessing.Pool() as pool: 91 | pool.map(self.build_impl, sorted(self.grouped_strings_file_paths)) 92 | 93 | def build_impl(self, pack_id): 94 | logging.info("Packing: " + pack_id) 95 | string_resources_file_paths = self.grouped_strings_file_paths[pack_id] 96 | 97 | string_pack.build( 98 | string_resources_file_paths, 99 | get_dest_pack_file_path(self.sp_config, pack_id), 100 | self.id_finder, 101 | self.plural_handler, 102 | ) 103 | 104 | 105 | def noop_plural_handler(locale, comment, quantity): 106 | """Customizable function to return true if an item should not be packed. 107 | 108 | Could be customized to strip out plural cases that are known to not be used. 109 | For example, if the plural selector in a plural sting is always larger than 1, 110 | there's no need to pack the 'one' case for many languages.""" 111 | return False 112 | 113 | 114 | def main(): 115 | arg_parser = argparse.ArgumentParser() 116 | arg_parser.add_argument("--config", help="Location of JSON config file.") 117 | 118 | args = arg_parser.parse_args() 119 | sp_config = string_pack_config.load_config(config_json_file_path=args.config) 120 | 121 | pack_strings(sp_config, noop_plural_handler) 122 | 123 | 124 | if __name__ == "__main__": 125 | main() 126 | -------------------------------------------------------------------------------- /library/scripts/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhatsApp/StringPacks/4f129f358d4525d489bae38840f8be7c9b8b752d/library/scripts/resources/__init__.py -------------------------------------------------------------------------------- /library/scripts/resources/default_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "original_resources_directories": [ 3 | "app/src/main/" 4 | ], 5 | "find_resource_files_command": "git ls-files '**.xml' | grep -E '/(src|res)/'", 6 | "assets_directory": "app/src/main/assets/", 7 | "safe_widget_classes": [ 8 | ], 9 | "languages_to_pack": ["*"], 10 | "languages_to_drop": [], 11 | "pack_id_mapping": {}, 12 | "pack_scripts_directory": "scripts/" 13 | } 14 | -------------------------------------------------------------------------------- /library/scripts/string_pack_config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 2 | # 3 | # This source code is licensed under the Apache 2.0 license found in 4 | # the LICENSE file in the root directory of this source tree. 5 | 6 | import enum 7 | import json 8 | import os 9 | from importlib import resources 10 | 11 | 12 | def load_config( 13 | config_json_dict=None, config_json_file_path=None, skip_default_config=False 14 | ) -> "StringPackConfig": 15 | if ( 16 | config_json_dict is None 17 | and config_json_file_path is None 18 | and skip_default_config 19 | ): 20 | raise ValueError( 21 | "Please specify at least one type for loading StringPackConfig." 22 | ) 23 | 24 | sp_config = StringPackConfig() 25 | 26 | if not skip_default_config: 27 | with resources.path("resources", "default_config.json") as path: 28 | sp_config.load_from_file(path) 29 | 30 | if config_json_file_path is not None: 31 | sp_config.load_from_file(config_json_file_path) 32 | 33 | if config_json_dict is not None: 34 | sp_config.load_from_dict(config_json_dict) 35 | 36 | return sp_config 37 | 38 | 39 | def is_valid_language_qualifier(resource_qualifier): 40 | if len(resource_qualifier) == 2: 41 | # language only, e.g.: en 42 | return True 43 | 44 | if len(resource_qualifier) == 6 and resource_qualifier[2:4] == "-r": 45 | # language with region. e.g.: fr-rCA 46 | return True 47 | 48 | if resource_qualifier.startswith("b+") and "-" not in resource_qualifier: 49 | # special language: e.g.: b+es+419 50 | return True 51 | 52 | return False 53 | 54 | 55 | class LanguageHandlingCase(enum.Enum): 56 | """Determine how should string packs handles strings for that language""" 57 | 58 | # Keep original file in project, don't pack them. This could happen for non-language qualifier, e.g. values-land. 59 | KEEP_ORIGINAL = 0 60 | # Pack them at eventually, so move the string resources to intermediate file 61 | PACK = 1 62 | # Remove the strings from original resource file, but don't move to intermediate file 63 | # See StringPackConfig.languages_to_drop 64 | DROP = 2 65 | 66 | 67 | class StringPackConfig: 68 | """ 69 | Configurations for running string packs scripts. 70 | 71 | This provides the configuration information for string pack scripts to generate the intermediate files, and save 72 | final content at the right place. 73 | 74 | """ 75 | 76 | FIELDS_TO_OVERRIDE = [ 77 | "module", 78 | "original_resources_directories", 79 | "destination_stringpack_directories", 80 | "find_resource_files_command", 81 | "languages_to_pack", 82 | "languages_to_drop", 83 | "assets_directory", 84 | "pack_ids_class_file_path", 85 | "resource_config_setting", 86 | "pack_id_mapping", 87 | "pack_scripts_directory", 88 | ] 89 | 90 | def __init__(self): 91 | # The project module that string packs are used for. 92 | self.module = None 93 | 94 | # The directories above the Android resources directories, where the script can find values/strings.xml and values-xx/ directories. 95 | # (i.e. app/src/main) 96 | # These directories will also hold the files of the packable strings. 97 | # FIND operation checks each directory's res subdirectory for values/strings.xml and values-xx/.strings.xml files. The ids are stored in pack_ids_class_file_path. 98 | # MOVE operation moves packable strings found in FIND to the directory's string-packs/strings subdirectory. 99 | # PACK operation packs the files in the directory's string-packs/strings subdirectory into .pack files in assets_directory. 100 | self.original_resources_directories = [] 101 | 102 | # The directories where the script will save the strings.xml that need to be stringpacked after the MOVE command. 103 | # Format: 104 | # {"original_resource_directory": "destination_stringpack_xml_directory"} 105 | # This allows saving values-xx/strings.xml (candidates for PACK command) files for a module at any custom location and not necessarily at the 106 | # original_resource_directory/string-packs/strings subdirectory. 107 | # The script will create the destination_stringpack_xml_directory if it doesn't exist. 108 | # If the destination_stringpack_xml_directory is not specified, the script will save the files at the original_resource_directory/string-packs/strings subdirectory. 109 | # If the destination_stringpack_xml_directory is specified, the script will save the files at the destination_stringpack_xml_directory/string-packs/strings subdirectory. 110 | # MOVE - If a original_resource_directory key is present, it will copy to the new destination directory, else original_resource_directory 111 | # PACK - If a original_resource_directory key is present, it will pack from the new destination directory, else original_resource_directory 112 | self.destination_stringpack_directories = {} 113 | 114 | # Executable command line that returns all resource files for parsing movable strings. 115 | self.find_resource_files_command = None 116 | 117 | # List of languages that need to be packed, or ["*"] means all languages. 118 | self.languages_to_pack = [] 119 | 120 | # List of languages that don't need to be packed, and they could be safely removed. 121 | # e.g.: "zh-rTW" could be drop if "zh-rHK" is set the same, the app would pick it correctly. 122 | self.languages_to_drop = [] 123 | 124 | # The full class name of custom widgets that are already using StringPack resources reader. 125 | self.safe_widget_classes = set() 126 | 127 | # The assets directory where to save the generated string pack files. 128 | self.assets_directory = None 129 | 130 | # File path to the class where stores the map from android string key to pack id. Not compatible with `resource_config_setting`. 131 | self.pack_ids_class_file_path = None 132 | 133 | # Settings dict for aapt2 config that overrides the android string id. Not compatible with `pack_ids_class_file_path`. 134 | # All properties below are mandatory entries in the dict. 135 | # - `config_file_path` file path for aapt2 config to set stable id 136 | # - `source_file_path` for file path of generated Java source 137 | # - `string_offset` a hex string for string id offset (usually "0x7f120000") 138 | # - `plurals_offset` a hex string for plural id offset (usually "0x7f100000") 139 | # - `package_name` for package name. 140 | # 141 | # Change the apk build script to ensure this takes effect in build process. 142 | # android.androidResources.additionalParameters "--stable-ids", config_file_path 143 | self.resource_config_setting = None 144 | 145 | # A dictionary that maps a specific language to its pack ID. The default pack ID is the language code, but the 146 | # app may decide to pack similar languages to one pack file. 147 | # For example, it may save space to pack Czech and Slovak in one pack file, assuming the app knows where to 148 | # look for them. 149 | self.pack_id_mapping = {} 150 | 151 | # The directory that holds all the python scripts 152 | self.pack_scripts_directory = None 153 | 154 | def load_from_file(self, config_json_file_path): 155 | """Load configuration from json file.""" 156 | 157 | with open(config_json_file_path) as file_content: 158 | config_json_dict = json.load(file_content) 159 | 160 | self.load_from_dict(config_json_dict) 161 | 162 | def load_from_dict(self, config_dict): 163 | """Load configuration from a dictionary which servers as json. 164 | 165 | All configuration values would be overridden with the value that provided in config_json, except 166 | `safe_widget_classes` which new widget class names would be append to it. 167 | """ 168 | 169 | for field in StringPackConfig.FIELDS_TO_OVERRIDE: 170 | if field in config_dict: 171 | setattr(self, field, config_dict[field]) 172 | 173 | # Append instead of override 174 | if "safe_widget_classes" in config_dict: 175 | self.safe_widget_classes.update(config_dict["safe_widget_classes"]) 176 | 177 | return self 178 | 179 | def get_default_string_files(self): 180 | return [ 181 | os.path.join(source_directory, "res", "values", "strings.xml") 182 | for source_directory in self.original_resources_directories 183 | ] 184 | 185 | def get_handling_case(self, resource_qualifier): 186 | """Determine how to handle the string for given resource qualifier if it's a language qualifier.""" 187 | 188 | if resource_qualifier in self.languages_to_drop: 189 | return LanguageHandlingCase.DROP 190 | 191 | if self.languages_to_pack == ["*"]: 192 | # Match all valid language 193 | if is_valid_language_qualifier(resource_qualifier): 194 | return LanguageHandlingCase.PACK 195 | elif resource_qualifier in self.languages_to_pack: 196 | return LanguageHandlingCase.PACK 197 | 198 | return LanguageHandlingCase.KEEP_ORIGINAL 199 | -------------------------------------------------------------------------------- /library/scripts/tests/README.md: -------------------------------------------------------------------------------- 1 | # StringPacks - Unit Tests 2 | 3 | This folder contains the unit tests for the StringPacks python scripts. 4 | 5 | ## Run Tests 6 | 7 | Those tests have to be run as python module in the **parent directory** with 8 | python3. 9 | 10 | To run all the tests: 11 | ```bash 12 | python3 -m unittest discover tests 13 | ``` 14 | 15 | To run specific test file, just reference file name: 16 | ```bash 17 | python3 -m unittest tests/test_pack_strings.py 18 | ``` 19 | 20 | For more information about python `unittest`, check [the official documentation](https://docs.python.org/3/library/unittest.html). -------------------------------------------------------------------------------- /library/scripts/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WhatsApp/StringPacks/4f129f358d4525d489bae38840f8be7c9b8b752d/library/scripts/tests/__init__.py -------------------------------------------------------------------------------- /library/scripts/tests/res/Expected.java: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 2 | * 3 | * This source code is licensed under the Apache 2.0 license found in 4 | * the LICENSE file in the root directory of this source tree. 5 | */ 6 | 7 | package com.whatsapp.example; 8 | 9 | public class IdsRange { 10 | // region StringPacks ID range 11 | private static final int STRING_BEGIN = 0x7f120000; 12 | private static final int STRING_END = 0x7f120001; 13 | private static final int PLURALS_BEGIN = 0x7f110000; 14 | private static final int PLURALS_END = 0x7f110000; 15 | 16 | // endregion 17 | public void doSomething() {} 18 | } 19 | -------------------------------------------------------------------------------- /library/scripts/tests/res/Test.java: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. 2 | * 3 | * This source code is licensed under the Apache 2.0 license found in 4 | * the LICENSE file in the root directory of this source tree. 5 | */ 6 | 7 | package com.whatsapp.example; 8 | 9 | public class IdsRange { 10 | // region StringPacks ID range 11 | // endregion 12 | public void doSomething() {} 13 | } 14 | -------------------------------------------------------------------------------- /library/scripts/tests/res/expected_resources.txt: -------------------------------------------------------------------------------- 1 | com.example:plurals/people = 0x7f110000 2 | com.example:string/no = 0x7f120000 3 | com.example:string/yes = 0x7f120001 -------------------------------------------------------------------------------- /library/scripts/tests/res/test_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 26 | 27 | 32 | 33 | 42 | 43 | 49 | 50 | 51 | 52 |