├── .gitignore ├── LICENSE ├── README.md ├── apktool.py ├── assemble.py ├── bin ├── AXMLPrinter.jar ├── VasDolly.jar └── diffuse.jar ├── channel.py ├── config ├── config.yml └── template_diff.html ├── diffuse.py ├── docs └── README-zh.md ├── files.py ├── git_tag.py ├── global_config.py ├── lanzou.py ├── logger.py ├── mailing.py ├── publish.py ├── resources.py ├── run.py ├── strengthen.py └── telegram_bot.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/* 2 | app.log 3 | config/config_product.yml 4 | .DS_Store -------------------------------------------------------------------------------- /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 | # Auto Package 2 | 3 | [中文版](docs/README-zh.md) 4 | 5 | ## 1. What? 6 | 7 | An Android auto package script. Mainly used to 8 | 9 | - Call gradlew command to package APKs, 32 bit and 64 bit separately 10 | - Copy APKs to given directory 11 | - Diff APK with last version or output its info base on [diffuse](https://github.com/JakeWharton/diffuse) 12 | - Copy language resources to given dirctory, commit to github repo for translation cooperation 13 | - Add git tag automatically and push to remote git repo 14 | - Automatically generate APP upgrade log from git logs 15 | - Reinforce APKs by [360 Security](https://jiagu.360.cn/#/global/index) 16 | - Package APKs in multi-channel by [VasDolly](https://github.com/Tencent/VasDolly) 17 | - Upload APKs to lanzou cloud 18 | - Send APK and upgrade log to Telegram group by bot 19 | - Notify receivers when succeed by email 20 | - More in the future. 21 | 22 | ## 2. How? 23 | 24 | ### 2.1 Prepare 25 | 26 | - Python: Python3 27 | - Add `pyymal` library to your environment by: `pip install pyyaml` 28 | - And `requests` library to your environment by: `pip install requests` 29 | - And `requests_toolbelt` library to your environment by: `pip install requests_toolbelt` 30 | 31 | ### 2.2 Use 32 | 33 | - Configure [the configuration file](config.yml). 34 | - Execute `python run.py` under root directory. 35 | -------------------------------------------------------------------------------- /apktool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging, os 5 | import zipfile, traceback 6 | import xml.dom.minidom 7 | from global_config import * 8 | 9 | class ApkInfo: 10 | 11 | def __init__(self, \ 12 | source_apk_file_path: str = '', \ 13 | package: str = '', \ 14 | version_name: str = '', \ 15 | version_code: str = '' 16 | ) -> None: 17 | ''' 18 | The APK info. 19 | - source_apk_file_path: source apk path 20 | - package: package name 21 | - version_name: app version name 22 | - version_code: app version code 23 | ''' 24 | self.source_apk_file_path = source_apk_file_path 25 | self.package = package 26 | self.version_code = version_code 27 | self.version_name = version_name 28 | self.build_bit:BitConfiguration = None 29 | self.build_flavor: FlavorConfiguration = None 30 | self.output_apk_directory = '' 31 | self.output_apk_file_path = '' 32 | 33 | def is_valid(self) -> bool: 34 | '''Is given APKInfo valid.''' 35 | return len(self.source_apk_file_path) > 0 36 | 37 | def __str__(self) -> str: 38 | return "ApkInfo([%s][%s][%s][%s])" % (self.source_apk_file_path, self.package, self.version_name, self.version_code) 39 | 40 | def parse_apk_info(apk_file_path: str) -> ApkInfo: 41 | ''' 42 | Parse APK info. 43 | - apk_file_path: path of APK file. 44 | ''' 45 | logging.info("Trying to get apk info from [%s]" % apk_file_path) 46 | zipFile = zipfile.ZipFile(apk_file_path) 47 | zipFile.extract("AndroidManifest.xml", '.') 48 | zipFile.close() 49 | # out = os.popen("java -jar ../bin/AXMLPrinter.jar %s" % ('AndroidManifest.xml')).read().strip() 50 | out = os.popen("java -jar bin/AXMLPrinter.jar %s" % ('AndroidManifest.xml')).read().strip() 51 | try: 52 | root = xml.dom.minidom.parseString(out) 53 | collection = root.documentElement 54 | vcode = collection.getAttribute('android:versionCode') 55 | vname = collection.getAttribute('android:versionName') 56 | pkg = collection.getAttribute('package') 57 | return ApkInfo(apk_file_path, pkg, vname, vcode) 58 | except Exception: 59 | logging.error("Failed to parse Android manifest: %s" % traceback.format_exc()) 60 | finally: 61 | os.remove('AndroidManifest.xml') 62 | return ApkInfo() 63 | -------------------------------------------------------------------------------- /assemble.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from files import * 5 | from apktool import * 6 | from global_config import * 7 | from logger import * 8 | from channel import * 9 | 10 | def assemble(bit: BitConfiguration, flavor: FlavorConfiguration) -> ApkInfo: 11 | '''Assemble APK with bit and flavor and copy APK and mapping files to destination.''' 12 | # ./gradlew assembleNationalDebug -Pbuild_ndk_type=ndk_32 -Pversion_name=3.8.0 13 | assemble_command = "cd %s && ./gradlew clean %s -Pbuild_ndk_type=%s" \ 14 | % (config.gradlew_location, flavor.get_gradlew_command(), bit.get_gradlew_bit_param_value()) 15 | if len(config.gradle_java_home) > 0: 16 | assemble_command = assemble_command + (" -Dorg.gradle.java.home=\"%s\"" % config.gradle_java_home) 17 | if len(build_config.version) != 0: 18 | assemble_command = assemble_command + " -Pversion_name=" + build_config.version 19 | logi("Final gradlew command is [%s]" % assemble_command) 20 | os.system(assemble_command) 21 | info = _find_apk_under_given_directory(bit, flavor) 22 | _copy_apk_to_directory(info) 23 | _copy_mapping_file_to_directory(info, flavor) 24 | _package_apk_channels(info) 25 | return info 26 | 27 | def _find_apk_under_given_directory(bit: BitConfiguration, flavor: FlavorConfiguration) -> ApkInfo: 28 | '''Get destination directory name.''' 29 | apk_output_directory = flavor.get_apk_output_directory() 30 | files = os.listdir(apk_output_directory) 31 | for f in files: 32 | if f.endswith('apk'): 33 | path = os.path.join(apk_output_directory, f) 34 | info = parse_apk_info(path) 35 | break 36 | # Fill bit and flavor configuration. 37 | if info is not None: 38 | info.build_bit = bit 39 | info.build_flavor = flavor 40 | return info 41 | 42 | def _copy_apk_to_directory(info: ApkInfo): 43 | '''Copy APK from one directory to another.''' 44 | if not info.is_valid(): 45 | loge("The APK info is invalid.") 46 | return 47 | output_apk_directory = os.path.join(config.output_apk_directory, '%s_%s' % (info.version_name, info.version_code)) 48 | if not os.path.exists(output_apk_directory): 49 | os.makedirs(output_apk_directory) 50 | apk_file_base_name = os.path.basename(info.source_apk_file_path) 51 | output_apk_file_path = os.path.join(output_apk_directory, apk_file_base_name) 52 | copy_to(info.source_apk_file_path, output_apk_file_path) 53 | # Fill in APk info. 54 | info.output_apk_directory = output_apk_directory 55 | info.output_apk_file_path = output_apk_file_path 56 | 57 | def _copy_mapping_file_to_directory(info: ApkInfo, flavor: FlavorConfiguration): 58 | '''Copy mapping file to given directory.''' 59 | mapping_file_name = "%s_mapping.txt" % flavor.get_name() 60 | mapping_file_copy_to = os.path.join(info.output_apk_directory, mapping_file_name) 61 | copy_to(config.mapping_file_path, mapping_file_copy_to) 62 | 63 | def _package_apk_channels(info: ApkInfo): 64 | '''Package App with different channels.''' 65 | generate_apk_channels(info.source_apk_file_path, config.output_channels, info.output_apk_directory) 66 | -------------------------------------------------------------------------------- /bin/AXMLPrinter.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shouheng88/autopackage/55a873fd95c49cd71d6bbc0ff1d887511de4bf90/bin/AXMLPrinter.jar -------------------------------------------------------------------------------- /bin/VasDolly.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shouheng88/autopackage/55a873fd95c49cd71d6bbc0ff1d887511de4bf90/bin/VasDolly.jar -------------------------------------------------------------------------------- /bin/diffuse.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shouheng88/autopackage/55a873fd95c49cd71d6bbc0ff1d887511de4bf90/bin/diffuse.jar -------------------------------------------------------------------------------- /channel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from logger import * 6 | 7 | def generate_apk_channels(apk_path: str, channels: [str], output_dir): 8 | '''Generate APK with channels.''' 9 | if len(channels) == 0: 10 | logi('No channels to generate, ignored ...') 11 | return True 12 | channels_str = ','.join(channels) 13 | os.system("java -jar bin/VasDolly.jar put -c \"%s\" %s %s" % (channels_str, apk_path, output_dir)) 14 | -------------------------------------------------------------------------------- /config/config.yml: -------------------------------------------------------------------------------- 1 | # APK Build Configurations. 2 | build: 3 | # Gradlew file location directory. 4 | gradlew_location: ../ 5 | # Gradlew build commands. 6 | assemble_command: 7 | national: resguardNationalRelease 8 | oversea: resguardInternationalRelease 9 | # Gradlew APK output diectories. 10 | apk_output_directory: 11 | national: ../app/build/outputs/apk/national/release 12 | oversea: ../app/build/outputs/apk/international/release 13 | # The mapping file location. 14 | mapping_file_path: ../app/mapping.txt 15 | # Custom JDK path 16 | gradle_java_home: 17 | # APK Strength Configurations. 18 | strengthen: 19 | enable: true 20 | jiagu_360: 21 | # 360 jiaguo executor jar file location. 22 | executor_path: xxxxxxxxxx 23 | account: xxxxxxxxxx 24 | password: xxxxxxxxxx 25 | # Final Output Configurations. 26 | output: 27 | # APK files store directory. 28 | apk_directory: D:/codes/other/LeafNote-resources/apks 29 | # Language files store directory. 30 | languages_directory: D:/codes/other/LeafNote-Community/languages/app 31 | # APK Info Output Email. 32 | mail: 33 | title: xxxxxxxxxx 34 | receivers: 35 | - xxxxxxxxxx 36 | - xxxxxxxxxx 37 | user: xxxxxxxxxx 38 | password: xxxxxxxxxx 39 | # Git log store file path. 40 | git_log_store_file: D:/codes/other/LeafNote-Community/GITLOG.md 41 | # Channels to package. 42 | channels: 43 | - xxxxxxxxx 44 | - xxxxxxxxx 45 | # APK Publish Configurations. 46 | publish: 47 | # LanZou Cloud Info. 48 | lanzou: 49 | username: xxxxxxxxxx 50 | password: xxxxxxxxxx 51 | ylogin: xxxxxxxxxx 52 | phpdisk_info: xxxxxxxxxx 53 | # Telegram Chat Info. 54 | telegram: 55 | chat_id: xxxxxxxxxx 56 | token: xxxxxxxxxx 57 | -------------------------------------------------------------------------------- /config/template_diff.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 |
11 |     
12 | diff_result_content
13 |     
14 | 
15 | 16 | -------------------------------------------------------------------------------- /diffuse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | from apktool import * 6 | from files import * 7 | from global_config import * 8 | 9 | DIFF_RESULT_HTML_TEMPLATE = "config/template_diff.html" 10 | DIFF_RESULT_HTML_PLACEHOLER = "diff_result_content" 11 | 12 | def diff_apk(info: ApkInfo) -> str: 13 | '''Diff APK or output its information.''' 14 | last_version_directory = _find_last_version_directory(info) 15 | logd("Found last version directory: %s" % last_version_directory) 16 | diff_result = '' 17 | if len(last_version_directory) == 0: 18 | diff_result = _output_current_apk_information(info) 19 | else: 20 | diff_result = _output_apk_diff_result(info, last_version_directory) 21 | # Format diff result as a html content. 22 | html_diff_content = read_file(DIFF_RESULT_HTML_TEMPLATE) 23 | html_diff_content = html_diff_content.replace(DIFF_RESULT_HTML_PLACEHOLER, diff_result) 24 | return html_diff_content 25 | 26 | def _find_last_version_directory(info: ApkInfo) -> str: 27 | '''Find last APK version by APK output directory.''' 28 | last_version = 0 29 | last_version_directory = '' 30 | output_apk_directory = config.output_apk_directory 31 | logd("finding last version under %s" % (output_apk_directory)) 32 | for directory in os.listdir(output_apk_directory): 33 | logd("finding last version under %s" % (directory)) 34 | directory_parts = directory.split('_') 35 | if directory != ".DS_Store" and len(directory_parts) > 1: 36 | version_code = directory_parts[1] 37 | if not info.version_code == version_code and int(version_code) > last_version: 38 | last_version = int(version_code) 39 | last_version_directory = directory 40 | if len(last_version_directory) == 0: 41 | return '' 42 | return os.path.join(output_apk_directory, last_version_directory) 43 | 44 | def _output_current_apk_information(info: ApkInfo) -> str: 45 | '''Output current APK information.''' 46 | diff_result_content = os.popen("java -jar bin/diffuse.jar info %s" % info.output_apk_file_path).read().strip() 47 | diff_result_file_name = os.path.join(info.output_apk_directory, \ 48 | "diff_result_%s_%s_info.txt" % (info.build_flavor.get_name(), info.build_bit.get_name())) 49 | write_file(diff_result_file_name, diff_result_content) 50 | return diff_result_content 51 | 52 | def _get_last_version_apk_file_path(info: ApkInfo, last_version_directory: str) -> str: 53 | '''Get last version APK file path.''' 54 | bit_name = info.build_bit.get_name() 55 | flavor_name = info.build_flavor.get_name() 56 | for file_name in os.listdir(last_version_directory): 57 | if file_name.endswith(".apk") and file_name.find(bit_name) >= 0 and file_name.find(flavor_name) >= 0: 58 | return os.path.join(last_version_directory, file_name) 59 | return '' 60 | 61 | def _output_apk_diff_result(info: ApkInfo, last_version_directory: str) -> str: 62 | '''Output APKs diff result.''' 63 | last_version_apk_file_path = _get_last_version_apk_file_path(info, last_version_directory) 64 | if len(last_version_apk_file_path) == 0: 65 | logi("Last version APK file not found!") 66 | return _output_current_apk_information(info) 67 | logi("Found last version APK file: %s" % last_version_apk_file_path) 68 | diff_result_content = os.popen("java -jar bin/diffuse.jar diff %s %s" % \ 69 | (last_version_apk_file_path, info.output_apk_file_path)).read().strip() 70 | diff_result_file_name = os.path.join(info.output_apk_directory, \ 71 | "diff_result_%s_%s_info.txt" % (info.build_flavor.get_name(), info.build_bit.get_name())) 72 | write_file(diff_result_file_name, diff_result_content) 73 | return diff_result_content 74 | -------------------------------------------------------------------------------- /docs/README-zh.md: -------------------------------------------------------------------------------- 1 | # Android 自动打包脚本 2 | 3 | [English](README.md) 4 | 5 | ## 1. 关于 6 | 7 | 该项目是适用于 Android 的自动打包脚本,主要功能: 8 | 9 | - 使用 gradle 指令自动打包,区分 32 位和 64 位 10 | - 打包完成之后将 APK 拷贝到指定的目录 11 | - 使用 [diffuse](https://github.com/JakeWharton/diffuse) 输出相对于上一个版本的 APK 版本差异报告 12 | - 拷贝多语言资源到指定的目录,并自动提交到 Github 仓库以便于协助翻译 13 | - 自动打 tag 并提交到远程仓库 14 | - 根据 Git 提交记录自动生成更新日志 15 | - 使用 [360 加固](https://jiagu.360.cn/#/global/index) 对上述 APK 进行加固并输出到指定的目录 16 | - 基于 [VasDolly](https://github.com/Tencent/VasDolly) 进行多渠道打包 17 | - 上传打包 APK 到蓝奏云 18 | - 通过 Telegram bot 将打包完成的渠道包和更新日志信息发送到 Telegram 群组 19 | - 完成上述操作之后使用邮件通知打包结果 20 | - 未来,更多功能... 21 | 22 | ## 2. 使用 23 | 24 | ### 2.1 环境 25 | 26 | - Python: Python3 27 | - 添加 `pyymal` 库: `pip install pyyaml` 28 | - 添加 `requests` 库: `pip install requests` 29 | - 添加 `requests_toolbelt` 库: `pip install requests_toolbelt` 30 | 31 | ### 2.2 使用 32 | 33 | - 编辑配置文件 [the configuration file](config.yml) 34 | - 在根目录使用 `python run.py` 执行脚本 35 | -------------------------------------------------------------------------------- /files.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import yaml, logging, traceback, shutil 5 | import xml.sax 6 | from typing import * 7 | 8 | ANDROID_STRING_ELEMENT_TAG_NAME_STRING = "string" 9 | ANDROID_STRING_ELEMENT_TAG_NAME_PLURALS = "plurals" 10 | ANDROID_STRING_ELEMENT_TAG_NAME_STRING_ARRAY = "string-array" 11 | 12 | def read_yaml(yaml_file: str): 13 | '''Read YAML.''' 14 | with open(yaml_file, 'r', encoding="utf-8") as f: 15 | return yaml.load(f, Loader=yaml.FullLoader) 16 | 17 | def read_file(fname) -> str: 18 | '''Read text from file.''' 19 | with open(fname, 'r', encoding="utf-8") as f: 20 | return f.read() 21 | 22 | def write_file(fname, content:str): 23 | '''Write content to file.''' 24 | with open(fname, 'w', encoding='utf-8') as f: 25 | f.write(content) 26 | 27 | def copy_to(f: str, t: str): 28 | '''Copy file from one plcae to another.''' 29 | try: 30 | shutil.copyfile(f, t) 31 | except Exception: 32 | logging.error("Error while copy file: %s" % traceback.format_exc()) 33 | 34 | def parse_xml(xmlfile: str) -> Dict[str, List[str]]: 35 | '''parse xml''' 36 | parser = xml.sax.make_parser() 37 | handler = AndroidStringHandler() 38 | parser.setContentHandler(handler) 39 | parser.parse(xmlfile) 40 | return handler.resources 41 | 42 | class AndroidStringHandler(xml.sax.ContentHandler): 43 | def __init__(self) -> None: 44 | super().__init__() 45 | self.resources = {} 46 | self.element_name = '' 47 | self.element_values = [] 48 | 49 | def startElement(self, name, attrs): 50 | if name == ANDROID_STRING_ELEMENT_TAG_NAME_STRING\ 51 | or name == ANDROID_STRING_ELEMENT_TAG_NAME_PLURALS\ 52 | or name == ANDROID_STRING_ELEMENT_TAG_NAME_STRING_ARRAY: 53 | self.element_name = attrs["name"] 54 | return super().startElement(name, attrs) 55 | 56 | def endElement(self, name): 57 | if name == ANDROID_STRING_ELEMENT_TAG_NAME_STRING\ 58 | or name == ANDROID_STRING_ELEMENT_TAG_NAME_PLURALS\ 59 | or name == ANDROID_STRING_ELEMENT_TAG_NAME_STRING_ARRAY: 60 | element_values = [] 61 | element_values.extend(self.element_values) 62 | self.resources[self.element_name] = element_values 63 | self.element_name = '' 64 | self.element_values = [] 65 | return super().endElement(name) 66 | 67 | def characters(self, content: str): 68 | if len(self.element_name) > 0: 69 | content_stripped = content.strip() 70 | if len(content_stripped) > 0: 71 | self.element_values.append(content_stripped) 72 | return super().characters(content) 73 | 74 | if __name__ == "__main__": 75 | value = parse_xml("/Users/wangshouheng/desktop/repo/github/leafnote/app/src/main/res/values/strings.xml") 76 | print(value) 77 | -------------------------------------------------------------------------------- /git_tag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging, os 5 | from apktool import * 6 | from files import * 7 | from logger import config_logging 8 | from pathlib import Path 9 | 10 | def add_new_tag(info: ApkInfo): 11 | '''Add current new tag.''' 12 | os.system("cd %s && git tag v%s && git push origin --tags" 13 | % (config.gradlew_location, info.version_name)) 14 | 15 | def gen_git_log(info: ApkInfo): 16 | '''Generate git tag logs.''' 17 | # Get last commit log pattern. 18 | last_git_tag = _find_last_git_tag() 19 | if len(last_git_tag) == 0: 20 | logi("Last git tag not found.") 21 | return 22 | _append_gitlog_to_markdown(info, last_git_tag) 23 | 24 | def _append_gitlog_to_markdown(info: ApkInfo, last_git_tag: str): 25 | '''Append gitlog to markdown gitlog file.''' 26 | po = os.popen("cd %s && git log %s..HEAD --oneline" % (config.gradlew_location, last_git_tag)) 27 | git_logs = po.buffer.read().decode('utf-8').strip() 28 | markdown_git_logs = ('## %s \n - ' % info.version_name) + '\n- '.join(git_logs.split('\n')) 29 | content = '' 30 | if os.path.exists(config.output_gitlog_store_file): 31 | content = read_file(config.output_gitlog_store_file) 32 | content = markdown_git_logs + '\n' + content 33 | write_file(config.output_gitlog_store_file, content) 34 | _commit_git_log_change_event(info) 35 | return git_logs 36 | 37 | def _commit_git_log_change_event(info: ApkInfo): 38 | '''Commit git log file to community repo.''' 39 | gitlog_store_file_path = Path(config.output_gitlog_store_file) 40 | gitlog_store_file_dir = gitlog_store_file_path.parent 41 | gitlog_store_file_name = os.path.basename(config.output_gitlog_store_file) 42 | os.system("cd %s\ 43 | && git pull \ 44 | && git add %s\ 45 | && git commit -m \"feat: add upgrade logs for %s\"\ 46 | && git push" 47 | % (gitlog_store_file_dir, gitlog_store_file_name, info.version_name)) 48 | 49 | def _find_last_git_tag() -> str: 50 | '''Get last version git tag''' 51 | last_version_name = os.popen( 52 | "cd %s && git describe --abbrev=0 --tags" % (config.gradlew_location)).read().strip() 53 | return last_version_name 54 | 55 | if __name__ == "__main__": 56 | config_logging() 57 | print(gen_git_log(ApkInfo(version_name="test"))) 58 | -------------------------------------------------------------------------------- /global_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from logger import * 5 | from files import * 6 | from enum import Enum 7 | from typing import Dict 8 | 9 | YAML_CONFIGURATION_FILE_PATH = "config/config.yml" 10 | 11 | class BuildConfiguration: 12 | '''Build Environment Configuration.''' 13 | def __init__(self): 14 | # Build target script 15 | self.target_script = YAML_CONFIGURATION_FILE_PATH 16 | # Build APK version 17 | self.version = '' 18 | # Build APK channels. 19 | self.channels = '' 20 | 21 | class BitConfiguration(Enum): 22 | '''Assemble APK package bit configuration, namely, 32 bit, 64 bit and 64 + 32 bit packages.''' 23 | BIT_32 = 0 24 | BIT_64 = 1 25 | BIT_32_64 = 2 26 | 27 | def get_name(self) -> str: 28 | '''Get bit configuration name.''' 29 | if self == BitConfiguration.BIT_32: 30 | return 'ndk_32' 31 | elif self == BitConfiguration.BIT_32_64: 32 | return 'ndk_32_64' 33 | else: 34 | return 'ndk_64' 35 | 36 | def get_gradlew_bit_param_value(self) -> str: 37 | '''Get gradlew build parameter for given ndk/bit configuration.''' 38 | if self == BitConfiguration.BIT_32: 39 | return 'ndk_32' 40 | elif self == BitConfiguration.BIT_32_64: 41 | return 'ndk_32_64' 42 | else: 43 | return 'ndk_64' 44 | 45 | class FlavorConfiguration(Enum): 46 | '''Assemble APK package flavor configuration, namely, national and international packages.''' 47 | NATIONAL = 0 48 | OVERSEA = 1 49 | 50 | def get_name(self) -> str: 51 | '''Get flavor name.''' 52 | if self == FlavorConfiguration.OVERSEA: 53 | return "oversea" 54 | return "national" 55 | 56 | def get_gradlew_command(self) -> str: 57 | '''Get gradlew command configuration base on current flavor.''' 58 | if self == FlavorConfiguration.OVERSEA: 59 | return config._assemble_command_oversea 60 | return config._assemble_command_national 61 | 62 | def get_apk_output_directory(self) -> str: 63 | '''Get APK output directory base on current flavor.''' 64 | if self == FlavorConfiguration.OVERSEA: 65 | return config._apk_output_directory_oversea 66 | return config._apk_output_directory_national 67 | 68 | class GlobalConfig: 69 | def parse(self): 70 | self._configurations = read_yaml(build_config.target_script) 71 | logd(str(self._configurations)) 72 | # Gradlew Build Configurations. 73 | self.gradlew_location = self._read_key("build.gradlew_location") 74 | self._assemble_command_national = self._read_key("build.assemble_command.national") 75 | self._assemble_command_oversea = self._read_key("build.assemble_command.oversea") 76 | self._apk_output_directory_national = self._read_key('build.apk_output_directory.national') 77 | self._apk_output_directory_oversea = self._read_key('build.apk_output_directory.oversea') 78 | self.mapping_file_path = self._read_key("build.mapping_file_path") 79 | self.gradle_java_home = self._read_key("build.gradle_java_home") 80 | # APK Strength Configurations. 81 | self.strengthen_enable = self._read_key('strengthen.enable') 82 | self.strengthen_jiagu_360_executor_path = self._read_key('strengthen.jiagu_360.executor_path') 83 | self.strengthen_jiagu_360_account = self._read_key('strengthen.jiagu_360.account') 84 | self.strengthen_jiagu_360_password = self._read_key('strengthen.jiagu_360.password') 85 | # Final Output Configurations. 86 | self.output_apk_directory = self._read_key('output.apk_directory') 87 | self.output_languages_directory = self._read_key('output.languages_directory') 88 | self.output_mail_title = self._read_key('output.mail.title') 89 | self.output_mail_receivers = self._read_key('output.mail.receivers') 90 | self.output_mail_user = self._read_key('output.mail.user') 91 | self.output_mail_password = self._read_key('output.mail.password') 92 | self.output_gitlog_store_file = self._read_key('output.git_log_store_file') 93 | self.output_channels = self._read_key('output.channels') 94 | # APK Publish Configurations. 95 | self.publish_lanzou_username = self._read_key('publish.lanzou.username') 96 | self.publish_lanzou_password = self._read_key('publish.lanzou.password') 97 | self.publish_lanzou_ylogin = self._read_key('publish.lanzou.ylogin') 98 | self.publish_lanzou_phpdisk_info = self._read_key('publish.lanzou.phpdisk_info') 99 | self.publish_telegram_chat_id = self._read_key('publish.telegram.chat_id') 100 | self.publish_telegram_token = self._read_key('publish.telegram.token') 101 | 102 | def _read_key(self, key: str): 103 | '''Read key from configurations.''' 104 | parts = key.split('.') 105 | value = self._configurations 106 | for part in parts: 107 | value = value[part.strip()] 108 | return value 109 | 110 | # Global Build Configuration. 111 | build_config = BuildConfiguration() 112 | 113 | # Global Configuration From Target Script. 114 | config = GlobalConfig() 115 | 116 | if __name__ == "__main__": 117 | config_logging() 118 | print(config._assemble_command_national) 119 | print(config.output_mail_receivers) 120 | -------------------------------------------------------------------------------- /lanzou.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging, os, urllib, random, requests 5 | from apktool import * 6 | from files import * 7 | from global_config import * 8 | from logger import config_logging 9 | from typing import List 10 | from requests_toolbelt.multipart.encoder import MultipartEncoder 11 | 12 | fileup_url = r'http://up.woozooo.com/fileup.php' 13 | 14 | def upload(name: str, path: str): 15 | '''Upload file to lanzou cloud.''' 16 | logging.info("Uploading to lanzou cloud: %s" % f) 17 | name = urllib.parse.quote(name) 18 | fileup_headers = { 19 | "Accept": "* / *", 20 | "Accept - Encoding": "gzip, deflate, br", 21 | "DNT": "1", 22 | "Origin": "https://up.woozooo.com", 23 | "Referer": "https://up.woozooo.com/mydisk.php?item=files&action=index", 24 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36" 25 | } 26 | multipart_encoder = MultipartEncoder( 27 | fields={ 28 | "task": "1", 29 | "folder_id": "-1", 30 | "id": "WU_FILE_0", 31 | "name": f, 32 | "type": "application/octet-stream", 33 | # "lastModifiedDate": "Thu Jun 27 2019 12:11:16 GMT 0800 (中国标准时间)", 34 | # "size": "185", 35 | 'upload_file': (name, open(path, 'rb'), 'application/octet-stream') 36 | }, 37 | boundary='-----------------------------' + str(random.randint(1e28, 1e29 - 1)) 38 | ) 39 | fileup_headers['Content-Type'] = multipart_encoder.content_type 40 | fileup_json = session.post(url = fileup_url, data=multipart_encoder, headers=fileup_headers).json() 41 | if fileup_json['zt'] == 1: 42 | logging.inf("Succeed to upload: %s" % name) 43 | return fileup_json 44 | 45 | def a_upload(name: str, path: str): 46 | ''' 47 | - ylogin: the ylogin cookie from lanzou cloud, get cookie from 'chrome dev tools -> security -> cookie' 48 | - name: file name should have the extension info, and it should be supported your lanzou cloud account 49 | - path: the file path to upload 50 | ''' 51 | if len(config.publish_lanzou_ylogin) == 0: 52 | logging.error("lanzou cloud ylogin filed required!") 53 | return 54 | if len(config.publish_lanzou_phpdisk_info) == 0: 55 | logging.error("lanzou cloud phpdisk_info filed required!") 56 | return 57 | url_upload = "https://up.woozooo.com/fileup.php" 58 | headers = { 59 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36 Edg/89.0.774.45', 60 | 'Accept-Language': 'zh-CN,zh;q=0.9', 61 | 'Referer': f'https://up.woozooo.com/mydisk.php?item=files&action=index&u={config.publish_lanzou_ylogin}' 62 | } 63 | post_data = { 64 | "task": "1", 65 | "folder_id": "-1", 66 | "id": "WU_FILE_0", 67 | "name": name, 68 | } 69 | cookie = { 70 | 'ylogin': config.publish_lanzou_ylogin, 71 | 'phpdisk_info': config.publish_lanzou_phpdisk_info 72 | } 73 | files = {'upload_file': (name, open(path, "rb"), 'application/octet-stream')} 74 | multipart_encoder = MultipartEncoder( 75 | fields={ 76 | "task": "1", 77 | "folder_id": "-1", 78 | "id": "WU_FILE_0", 79 | "name": name, 80 | "type": "application/octet-stream", 81 | # "lastModifiedDate": "Thu Jun 27 2019 12:11:16 GMT 0800 (中国标准时间)", 82 | # "size": "185", 83 | 'upload_file': (name, open(path, 'rb'), 'application/octet-stream') 84 | }, 85 | boundary='-----------------------------' + str(random.randint(1e28, 1e29 - 1)) 86 | ) 87 | # headers['Content-Type'] = multipart_encoder.content_type 88 | # res = requests.post(url_upload, data=post_data, headers=headers, cookies=cookie, timeout=120).json() 89 | res = requests.post(url_upload, data=post_data, files=files, headers=headers, cookies=cookie, timeout=120).json() 90 | # res = requests.session().post(url = fileup_url, data=multipart_encoder, headers=headers).json() 91 | logging.info(res) 92 | return res['zt'] == 1 93 | 94 | def login(username: str, passed: str): 95 | '''Log in lanzou cloud.''' 96 | session = requests.session() 97 | 98 | login_url = r'https://up.woozooo.com/account.php' 99 | 100 | login_data = { 101 | "action": "login", 102 | "task": "login", 103 | "ref": "https://up.woozooo.com/", 104 | "formhash": "0af1aa15", 105 | "username": username, 106 | "password": passed, 107 | } 108 | 109 | mydisk_data = { 110 | "task": "5", 111 | "folder_id": "-1", 112 | "pg": "1", 113 | } 114 | 115 | headers = { 116 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36" 117 | } 118 | 119 | login_json = session.post(url=login_url, data=login_data, headers=headers).text 120 | return login_json 121 | 122 | def a_login(ylogin: str, phpdisk_info: str): 123 | headers = { 124 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36 Edg/89.0.774.45', 125 | 'Accept-Language': 'zh-CN,zh;q=0.9', 126 | 'Referer': 'https://pc.woozooo.com/account.php?action=login' 127 | } 128 | cookie = { 129 | 'ylogin': ylogin, 130 | 'phpdisk_info': phpdisk_info 131 | } 132 | url_account = "https://pc.woozooo.com/account.php" 133 | if cookie['phpdisk_info'] is None: 134 | logging.error('ERROR: phpdisk_info in Cookie is required!') 135 | return False 136 | if cookie['ylogin'] is None: 137 | logging.error('ERROR: ylogin in Cookie is required!') 138 | return False 139 | res = requests.get(url_account, headers=headers, cookies=cookie, verify=True) 140 | if '网盘用户登录' in res.text: 141 | log('ERROR: Failed to login lanzou cloud!') 142 | return False 143 | else: 144 | logging.info("Succeed to login lanzou cloud!") 145 | return True 146 | 147 | if __name__ == "__main__": 148 | config_logging() 149 | # ret = a_login(ylogin, phpdisk_info) 150 | # if ret: 151 | ret = a_upload("test2.apk", "D:\\codes\\other\\LeafNote-resources\\apks\\3.5.1_261\\32BIT-prod-release-3.5.1-261.apk") 152 | print(ret) 153 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | 6 | def config_logging(filename: str = 'app.log'): 7 | '''Config logging library globaly.''' 8 | log_format = "%(asctime)s - %(levelname)s - %(message)s" 9 | date_format = "%m/%d/%Y %H:%M:%S %p" 10 | logging.basicConfig(filename=filename, filemode='a', level=logging.DEBUG, format=log_format, datefmt=date_format) 11 | logging.FileHandler(filename=filename, encoding='utf-8') 12 | 13 | def logd(msg: str): 14 | '''D level log.''' 15 | print(msg) 16 | logging.debug(msg) 17 | 18 | def loge(msg: str): 19 | '''E level log.''' 20 | print(msg) 21 | logging.error(msg) 22 | 23 | def logi(msg: str): 24 | '''I level log.''' 25 | print(msg) 26 | logging.info(msg) 27 | -------------------------------------------------------------------------------- /mailing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import smtplib, traceback 5 | from email.header import Header 6 | from email import encoders 7 | from email.header import Header 8 | from email.mime.base import MIMEBase 9 | from email.mime.text import MIMEText 10 | from email.mime.multipart import MIMEMultipart 11 | from email.utils import parseaddr, formataddr 12 | from typing import * 13 | from logger import config_logging 14 | from global_config import * 15 | 16 | def send_email( 17 | receivers: List[str], 18 | subject: str, 19 | message: str, 20 | mail_type: str = 'plain', 21 | filename = None 22 | ): 23 | '''Send email tool.''' 24 | from_ = "%s<%s>" % (subject, config.output_mail_user) 25 | to_ = ';'.join(['Manager<%s>' % email_ for email_ in receivers]) 26 | msg = MIMEMultipart() 27 | msg['From'] = _format_addr(from_) 28 | msg['To'] = _format_addr(to_) 29 | msg['Subject'] = Header(subject, 'utf-8').encode() 30 | msg.attach(MIMEText(message, mail_type, 'utf-8')) 31 | if filename is not None: 32 | with open(filename, 'rb') as f: 33 | mime = MIMEBase('text', 'txt', filename='error.log') 34 | mime.add_header('Content-Disposition', 'attachment', filename='error.log') 35 | mime.add_header('Content-ID', '<0>') 36 | mime.add_header('X-Attachment-Id', '0') 37 | mime.set_payload(f.read()) 38 | encoders.encode_base64(mime) 39 | msg.attach(mime) 40 | try: 41 | smtpObj = smtplib.SMTP_SSL('smtp.qq.com') 42 | smtpObj.login(config.output_mail_user, config.output_mail_password) 43 | smtpObj.sendmail(config.output_mail_user, receivers, msg.as_string()) 44 | logi("Succeed to send email.") 45 | except BaseException as e: 46 | loge("Failed to send email:\n%s" % traceback.format_exc()) 47 | 48 | def _format_addr(s): 49 | name, addr = parseaddr(s) 50 | return formataddr((Header(name, 'utf-8').encode(), addr)) 51 | 52 | if __name__ == "__main__": 53 | '''Test entry.''' 54 | config_logging() 55 | logd(config.output_mail_receivers) 56 | logd(config.output_mail_user) 57 | logd(config.output_mail_password) 58 | send_email(config.output_mail_receivers, "测试", "测试") 59 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from global_config import * 6 | from logger import * 7 | from telegram_bot import send_apk 8 | from lanzou import a_upload 9 | from files import * 10 | from git_tag import COMMUNITY_LOGS_DIR 11 | 12 | def publish(app: str, ver: str): 13 | ''' 14 | Publish APKs. 15 | - app: APP name 16 | - ver: the version of APKs to publish 17 | ''' 18 | if ver is None or len(ver) == 0: 19 | loge("Failed: version code is invalid!") 20 | return 21 | dest_dir = '' 22 | for dir in os.listdir(config.output_apk_directory): 23 | if dir.startswith(ver + "_"): 24 | dest_dir = os.path.join(config.output_apk_directory, dir) 25 | break 26 | if len(dest_dir) == 0: 27 | loge("Failed to find APK of version: [%s]" % ver) 28 | return 29 | found_tg = 0 30 | found_lz = 0 31 | channels_dir = dest_dir + '/channels' 32 | for apk in os.listdir(channels_dir): 33 | if apk.endswith(".apk") and apk.find("telegram") != -1: 34 | found_tg = found_tg + 1 35 | path = os.path.join(channels_dir, apk) 36 | _publish_to_tg(app, path, ver) 37 | elif apk.endswith(".apk") and apk.find("lanzou") != -1: 38 | found_lz = found_lz + 1 39 | path = os.path.join(channels_dir, apk) 40 | _publish_to_lanzou(path, ver) 41 | logi("Published APKs for tG: %d" % (found_tg)) 42 | logi("Published APKs for Lanzou cloud: %d" % (found_lz)) 43 | 44 | def _publish_to_tg(app: str, apk: str, ver:str): 45 | '''Publish APK to TG.''' 46 | logd("Publishing [%s] to TG." % (apk)) 47 | msg = 'Hello friends! There\'s a new version [%s] of our %s for %s!\n' 48 | suffix = '' 49 | if apk.find("32BIT") != -1: 50 | suffix = '32bit' 51 | msg = msg % (ver, app, '32 bit') 52 | else: 53 | suffix = '64bit' 54 | msg = msg % (ver, app, '64 bit') 55 | upgrade_log = _read_upgrade_log(ver) 56 | if upgrade_log is None or len(upgrade_log) > 0: 57 | msg = msg + "\nUpgrade log (auto generated):\n" + upgrade_log 58 | msg = msg + "\n\nThis message is send by bot. If you have any questions, please contact the admin for help :P" 59 | send_apk(apk, "%s_v%s_%s.apk" % (app, ver, suffix), msg) 60 | 61 | def _publish_to_lanzou(apk: str, ver:str): 62 | '''Publish APK to Lanzou cloud.''' 63 | logd("Publishing %s to LZ." % (apk)) 64 | name = os.path.basename(apk) 65 | a_upload(name, apk) 66 | 67 | def _read_upgrade_log(ver: str) -> str: 68 | '''Read upgrade log generated!''' 69 | path = COMMUNITY_LOGS_DIR + "/" + ver + ".txt" 70 | if not os.path.exists(path): 71 | loge("Upgrade log not found: [%s]!" % path) 72 | return 73 | return read_file(path) 74 | 75 | if __name__ == "__main__": 76 | '''Test entry.''' 77 | config_logging() 78 | # print(_read_upgrade_log("3.5")) 79 | publish("LeafNote", "3.5") 80 | -------------------------------------------------------------------------------- /resources.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import shutil, os 5 | from logger import * 6 | from global_config import * 7 | from pathlib import Path 8 | from files import parse_xml 9 | 10 | ORIGINAL_COPY_TO_DIRECTORY_NAME = "original" 11 | ORIGINAL_COPY_TO_INFO_FILE_NAME = ".info" 12 | 13 | def copy_language_resources(version_name: str): 14 | '''Copy language resources to community repo and push to github.''' 15 | # Compare and then copy language resources. 16 | _compare_language_resources(version_name) 17 | _copy_origin_language_resources(version_name) 18 | 19 | def _compare_language_resources(version_name: str): 20 | '''Compare current language resources with last version.''' 21 | resoueces_directory = '%sapp/src/main/res/' % config.gradlew_location 22 | copied_language_directory = os.path.join(config.output_languages_directory, ORIGINAL_COPY_TO_DIRECTORY_NAME) 23 | copied_to_info_file_path = os.path.join(copied_language_directory, ORIGINAL_COPY_TO_INFO_FILE_NAME) 24 | last_version = version_name 25 | if os.path.exists(copied_to_info_file_path): 26 | last_version = read_file(copied_to_info_file_path).strip() 27 | diff_directory_name = "%s_to_%s" % (last_version, version_name) 28 | diff_directory_path = os.path.join(config.output_languages_directory, diff_directory_name) 29 | for resource_file_name in os.listdir(resoueces_directory): 30 | # Ignore directories don't start with 'values'. 31 | if not resource_file_name.startswith('values'): 32 | continue 33 | resource_file_path = os.path.join(resoueces_directory, resource_file_name) 34 | if Path(resource_file_path).is_dir(): 35 | # Compare in the child directory. Here we only compare two level directories! 36 | resource_directory_path = resource_file_path 37 | for resource_child_file_name in os.listdir(resource_directory_path): 38 | resource_child_file_path = os.path.join(resource_directory_path, resource_child_file_name) 39 | if Path(resource_child_file_path).is_file() and resource_child_file_name.endswith('.xml'): 40 | compare_from = resource_child_file_path 41 | compare_to = os.path.join(copied_language_directory, resource_file_name, resource_child_file_name) 42 | result_file_name = resource_child_file_name.replace(".xml", ".txt") 43 | write_to_directory = os.path.join(diff_directory_path, resource_file_name) 44 | write_to = os.path.join(write_to_directory, result_file_name) 45 | _compare_language_resource_file_and_output(compare_from, compare_to, write_to_directory, write_to) 46 | elif Path(resource_file_path).is_file() and resource_file_name.endswith('.xml'): 47 | compare_from = resource_file_path 48 | compare_to = os.path.join(copied_language_directory, resource_file_name) 49 | result_file_name = resource_file_name.replace(".xml", ".txt") 50 | write_to = os.path.join(diff_directory_path, result_file_name) 51 | _compare_language_resource_file_and_output(compare_from, compare_to, diff_directory_path, write_to) 52 | # Commit diff result. 53 | _commit_langauge_resources_diff_result(diff_directory_path, version_name) 54 | 55 | def _commit_langauge_resources_diff_result(diff_directory_path: str, version_name: str): 56 | '''Commit langauge resorces diff result.''' 57 | if os.path.exists(diff_directory_path): 58 | os.system("cd %s \ 59 | && git pull \ 60 | && git add . \ 61 | && git commit -m \"feat: add version %s language resources diff result\" \ 62 | && git push" % (diff_directory_path, version_name)) 63 | 64 | def _compare_language_resource_file_and_output(compare_from: str, compare_to: str, write_to_directory: str, write_to: str): 65 | '''Compare language resource file and output the diff result.''' 66 | logd("comparing language resource file from [%s] to [%s]" % (compare_from, compare_to)) 67 | from_dict = parse_xml(compare_from) 68 | to_dict = [] 69 | if os.path.exists(compare_to): 70 | to_dict = parse_xml(compare_to) 71 | compare_result: Dict[str, List[str]] = {} 72 | # Make a diff between string resources and old resources. 73 | for name, value in from_dict.items(): 74 | if name not in to_dict or "\n".join(value) != "\n".join(to_dict[name]): 75 | compare_result[name] = value 76 | # Build compare result file content and write to file. 77 | if len(compare_result) > 0: 78 | compare_content = '' 79 | for name, value in compare_result.items(): 80 | compare_content += (":name> " + name + "\n") 81 | compare_content += (":text> " + "\n".join(value) + "\n\n") 82 | if not os.path.exists(write_to_directory): 83 | os.makedirs(write_to_directory) 84 | write_file(write_to, compare_content) 85 | 86 | def _copy_origin_language_resources(version_name: str): 87 | '''Copy origin language resources.''' 88 | app_language_directory = os.path.join(config.output_languages_directory, ORIGINAL_COPY_TO_DIRECTORY_NAME) 89 | any_new_resources = False 90 | resoueces_directory = '%sapp/src/main/res/' % config.gradlew_location 91 | if not os.path.exists(resoueces_directory): 92 | loge("Resources directory doesn't exist or not match 'app/src/main/res/' pattern.") 93 | return 94 | for name in os.listdir(resoueces_directory): 95 | # Ignore directories don't start with 'values'. 96 | if name.startswith('values'): 97 | copy_from_directory = os.path.join(resoueces_directory, name) 98 | copy_to_directory = os.path.join(app_language_directory, name) 99 | if Path(copy_from_directory).is_dir(): 100 | # Delete the copy to directory if it exists before make a copy! 101 | if os.path.exists(copy_to_directory): 102 | shutil.rmtree(copy_to_directory) 103 | shutil.copytree(copy_from_directory, copy_to_directory) 104 | any_new_resources = _remove_not_language_files_under_directory(copy_to_directory) 105 | if any_new_resources: 106 | copy_to_info_file_path = os.path.join(app_language_directory, ORIGINAL_COPY_TO_INFO_FILE_NAME) 107 | write_file(copy_to_info_file_path, version_name) 108 | os.system("cd %s \ 109 | && git pull \ 110 | && git add . \ 111 | && git commit -m \"feat: version %s language resources\" \ 112 | && git push" % (app_language_directory, version_name)) 113 | 114 | def _remove_not_language_files_under_directory(directory: str) -> bool: 115 | '''Remove none langauge files under given directory.''' 116 | copy_to_directory_files = [directory] 117 | any_new_resources = False 118 | while len(copy_to_directory_files) > 0: 119 | copy_to_directory_file = copy_to_directory_files.pop() 120 | if Path(copy_to_directory_file).is_dir(): 121 | copy_to_directory_files.extend([os.path.join(copy_to_directory_file, part) for part in os.listdir(copy_to_directory_file)]) 122 | else: 123 | if not copy_to_directory_file.endswith(".xml")\ 124 | or len(parse_xml(copy_to_directory_file)) == 0: 125 | os.remove(copy_to_directory_file) 126 | else: 127 | any_new_resources = True 128 | return any_new_resources 129 | 130 | if __name__ == "__main__": 131 | config_logging() 132 | config.parse() 133 | copy_language_resources("3.8.3.5") 134 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os, shutil, logging, getopt, sys 5 | from assemble import assemble 6 | from diffuse import diff_apk 7 | from files import * 8 | from apktool import ApkInfo 9 | from logger import config_logging 10 | from git_tag import * 11 | from global_config import * 12 | from strengthen import jiagu_360 13 | from mailing import send_email 14 | from resources import * 15 | 16 | command_info = "\ 17 | Options: \n\ 18 | -h[--help] Help info\n\ 19 | -c[--script] Target build script path\n\ 20 | -v[--version] Build APK version\n\ 21 | -c[--channels] Build APK channels, split by ',' for multiple channels, for example 'oversea,national'" 22 | 23 | def _build_apk(bit: BitConfiguration, flavor: FlavorConfiguration) -> ApkInfo: 24 | '''Execute APK build flow.''' 25 | print(">>>> Beginning to build APK for flavor [%s] bit [%s]" % (flavor.get_name(), bit.get_name())) 26 | info = assemble(bit, flavor) 27 | diff = diff_apk(info) 28 | if config.strengthen_enable: 29 | jiagu_file_directory = os.path.join(info.output_apk_directory, 'strengthen') 30 | jiagu_360(info.output_apk_file_path, jiagu_file_directory) 31 | mail_subject = "%s(%s,%s)" % (config.output_mail_title, flavor.get_name(), bit.get_name()) 32 | send_email(config.output_mail_receivers, mail_subject, diff, 'html') 33 | return info 34 | 35 | def _show_invalid_command(info: str): 36 | '''Show command invalid info.''' 37 | print('Error: Unrecognized command: %s' % info) 38 | print(command_info) 39 | 40 | def _parse_command(argv): 41 | '''Parse command.''' 42 | try: 43 | opts, args = getopt.getopt(argv, "-h:-s:-v:-c:", ["help", "script=", 'version=', 'channels=']) 44 | except BaseException as e: 45 | _show_invalid_command(str(e)) 46 | sys.exit(2) 47 | for opt, arg in opts: 48 | print(arg) 49 | if opt in ('-s', '--script'): 50 | build_config.target_script = arg 51 | elif opt in ("-v", "--version"): 52 | build_config.version = arg 53 | elif opt in ("-c", "--channels"): 54 | build_config.channels = arg 55 | elif opt in ('-h', '--help'): 56 | print(command_info) 57 | logi("Build Info: target script[%s], version[%s] and channels[%s]" 58 | % (build_config.target_script, build_config.version, build_config.channels)) 59 | 60 | def _run_main(): 61 | '''Run main program.''' 62 | if len(build_config.channels) == 0: 63 | # _build_apk(BitConfiguration.BIT_32, FlavorConfiguration.NATIONAL) 64 | info = _build_apk(BitConfiguration.BIT_64, FlavorConfiguration.NATIONAL) 65 | info = _build_apk(BitConfiguration.BIT_64, FlavorConfiguration.OVERSEA) 66 | else: 67 | has_national = build_config.channels.find(FlavorConfiguration.NATIONAL.get_name()) >= 0 68 | has_oversea = build_config.channels.find(FlavorConfiguration.OVERSEA.get_name()) >= 0 69 | if has_national: 70 | _build_apk(BitConfiguration.BIT_32, FlavorConfiguration.NATIONAL) 71 | info = _build_apk(BitConfiguration.BIT_64, FlavorConfiguration.NATIONAL) 72 | if has_oversea: 73 | _build_apk(BitConfiguration.BIT_32, FlavorConfiguration.OVERSEA) 74 | info = _build_apk(BitConfiguration.BIT_64, FlavorConfiguration.OVERSEA) 75 | if not has_national and not has_oversea: 76 | print(">>>>> failed! due to no channels match!") 77 | return 78 | copy_language_resources(info.version_name) 79 | gen_git_log(info) 80 | add_new_tag(info) 81 | 82 | if __name__ == "__main__": 83 | ''' python3 run.py -s config/config_product.yml -v 4.0.3 ''' 84 | config_logging() 85 | _parse_command(sys.argv[1:]) 86 | config.parse() 87 | _run_main() 88 | -------------------------------------------------------------------------------- /strengthen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from global_config import * 6 | from logger import config_logging 7 | 8 | def jiagu_360(apk_file_path: str, output_directory: str): 9 | '''Reinforce the APP.''' 10 | if not os.path.exists(output_directory): 11 | os.mkdir(output_directory) 12 | os.system("java -jar %s -login %s %s\ 13 | && java -jar %s -jiagu %s %s -autosign -automulpkg"\ 14 | % (config.strengthen_jiagu_360_executor_path\ 15 | , config.strengthen_jiagu_360_account\ 16 | , config.strengthen_jiagu_360_password\ 17 | , config.strengthen_jiagu_360_executor_path\ 18 | , apk_file_path\ 19 | , output_directory 20 | ) 21 | ) 22 | 23 | if __name__ == "__main__": 24 | config_logging() 25 | config.parse() 26 | apk_path = "D:\\codes\\other\\LeafNote-resources\\apks\\3.5.1_261\\32BIT-prod-release-3.5.1-261.apk" 27 | out_dir = "D:\\codes\\other\\LeafNote-resources\\apks\\3.5.1_261" 28 | jiagu_360(apk_path, out_dir) 29 | # info = os.stat() 30 | # print(info.st_atime) 31 | -------------------------------------------------------------------------------- /telegram_bot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # import telebot 5 | import requests 6 | from global_config import * 7 | from logger import * 8 | 9 | # def send_message(tb: telebot.TeleBot, chat_id, msg): 10 | # tb.send_message(chat_id, msg) 11 | # print("Message Sent!") 12 | 13 | TG_SEND_DOCUMENT_URL = 'https://api.telegram.org/bot%s/sendDocument' 14 | 15 | def send_apk(path: str, name: str, msg: str): 16 | '''Send APK to TG channel by bot.''' 17 | if config.publish_telegram_chat_id is None or config.publish_telegram_chat_id == 0: 18 | loge("Failed to send APK to TG: chat id required!") 19 | return 20 | if config.publish_telegram_token is None or len(config.publish_telegram_token) == 0: 21 | loge("Failed to send APK to TG: chat token required!") 22 | return 23 | url = TG_SEND_DOCUMENT_URL % (config.publish_telegram_token) 24 | files = { 25 | 'document': (name, open(path, 'rb')) 26 | } 27 | ret = requests.post(url, data={ 28 | 'chat_id': config.publish_telegram_chat_id, 29 | 'caption': msg 30 | }, files=files) 31 | logi(ret.text) 32 | 33 | if __name__ == "__main__": 34 | '''Test entry.''' 35 | config_logging() 36 | config.parse() 37 | # tb = telebot.TeleBot("Bot") 38 | # tb.config['api_key'] = token 39 | # send_message(tb, chat_id, "This is funny. I'm using telegram bot to send message to you :D") 40 | send_apk('config.yml', "Hello!") 41 | --------------------------------------------------------------------------------