├── .github └── workflows │ └── gradle.yml ├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── jarRepositories.xml ├── libraries-with-intellij-classes.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── README.md ├── build.gradle ├── cover.jpeg ├── gpm.json ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── java │ └── com │ │ └── akdeniz │ │ └── googleplaycrawler │ │ ├── DownloadData.java │ │ ├── GooglePlay.java │ │ ├── GooglePlayAPI.java │ │ ├── GooglePlayException.java │ │ ├── Identity.java │ │ ├── Utils.java │ │ ├── gsf │ │ └── GoogleServicesFramework.java │ │ └── misc │ │ ├── Base64.java │ │ ├── DummyX509TrustManager.java │ │ └── HexDumpEncoder.java ├── kotlin │ └── com │ │ └── github │ │ └── theapache64 │ │ └── gpa │ │ ├── api │ │ ├── Play.kt │ │ └── PlayUtils.kt │ │ ├── core │ │ ├── SearchEngineResultPage.kt │ │ ├── Unwrap.kt │ │ └── net │ │ │ ├── DefaultTlsAuthentication.kt │ │ │ ├── DroidConnectionSocketFactory.kt │ │ │ ├── DroidSocket.kt │ │ │ ├── JellyBeanTlsClient.kt │ │ │ └── OrderedHashtable.kt │ │ └── model │ │ └── Account.kt └── resources │ └── com │ └── akdeniz │ └── googleplaycrawler │ └── crypt.properties └── test └── kotlin └── com └── github └── theapache64 └── gpa ├── api └── PlayTest.kt └── utils └── CoroutinesTestUtil.kt /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle 3 | 4 | name: Java CI with Gradle 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up JDK 1.8 20 | uses: actions/setup-java@v1 21 | with: 22 | java-version: 1.8 23 | - name: Grant execute permission for gradlew 24 | run: chmod +x gradlew 25 | - name: Build with Gradle 26 | env: 27 | PLAY_API_GOOGLE_USERNAME: ${{ secrets.PLAY_API_GOOGLE_USERNAME }} 28 | PLAY_API_GOOGLE_PASSWORD: ${{ secrets.PLAY_API_GOOGLE_PASSWORD }} 29 | run: ./gradlew build 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/kotlin,intellij,gradle 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,intellij,gradle 4 | 5 | ### Intellij ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/artifacts 37 | # .idea/compiler.xml 38 | # .idea/jarRepositories.xml 39 | # .idea/modules.xml 40 | # .idea/*.iml 41 | # .idea/modules 42 | # *.iml 43 | # *.ipr 44 | 45 | # CMake 46 | cmake-build-*/ 47 | 48 | # Mongo Explorer plugin 49 | .idea/**/mongoSettings.xml 50 | 51 | # File-based project format 52 | *.iws 53 | 54 | # IntelliJ 55 | out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Cursive Clojure plugin 64 | .idea/replstate.xml 65 | 66 | # Crashlytics plugin (for Android Studio and IntelliJ) 67 | com_crashlytics_export_strings.xml 68 | crashlytics.properties 69 | crashlytics-build.properties 70 | fabric.properties 71 | 72 | # Editor-based Rest Client 73 | .idea/httpRequests 74 | 75 | # Android studio 3.1+ serialized cache file 76 | .idea/caches/build_file_checksums.ser 77 | 78 | ### Intellij Patch ### 79 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 80 | 81 | # *.iml 82 | # modules.xml 83 | # .idea/misc.xml 84 | # *.ipr 85 | 86 | # Sonarlint plugin 87 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 88 | .idea/**/sonarlint/ 89 | 90 | # SonarQube Plugin 91 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 92 | .idea/**/sonarIssues.xml 93 | 94 | # Markdown Navigator plugin 95 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 96 | .idea/**/markdown-navigator.xml 97 | .idea/**/markdown-navigator-enh.xml 98 | .idea/**/markdown-navigator/ 99 | 100 | # Cache file creation bug 101 | # See https://youtrack.jetbrains.com/issue/JBR-2257 102 | .idea/$CACHE_FILE$ 103 | 104 | # CodeStream plugin 105 | # https://plugins.jetbrains.com/plugin/12206-codestream 106 | .idea/codestream.xml 107 | 108 | ### Kotlin ### 109 | # Compiled class file 110 | *.class 111 | 112 | # Log file 113 | *.log 114 | 115 | # BlueJ files 116 | *.ctxt 117 | 118 | # Mobile Tools for Java (J2ME) 119 | .mtj.tmp/ 120 | 121 | # Package Files # 122 | *.jar 123 | *.war 124 | *.nar 125 | *.ear 126 | *.zip 127 | *.tar.gz 128 | *.rar 129 | 130 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 131 | hs_err_pid* 132 | 133 | ### Gradle ### 134 | .gradle 135 | build/ 136 | 137 | # Ignore Gradle GUI config 138 | gradle-app.setting 139 | 140 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 141 | !gradle-wrapper.jar 142 | 143 | # Cache of project 144 | .gradletasknamecache 145 | 146 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 147 | # gradle/wrapper/gradle-wrapper.properties 148 | 149 | ### Gradle Patch ### 150 | **/build/ 151 | 152 | # End of https://www.toptal.com/developers/gitignore/api/kotlin,intellij,gradle 153 | build 154 | TestAccount.kt 155 | *.apk -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/libraries-with-intellij-classes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 64 | 65 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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 | ![](cover.jpeg) 2 | 3 | # google-play-api 4 | 5 | ![buildStatus](https://img.shields.io/github/workflow/status/theapache64/google-play-api/Java%20CI%20with%20Gradle?style=plastic) 6 | ![latestVersion](https://img.shields.io/github/v/release/theapache64/google-play-api) 7 | 8 | Twitter: theapache64 9 | 10 | 11 | > A coroutines based Kotlin library to access play store 12 | 13 | ## 🛠 Installation 14 | 15 | ```groovy 16 | repositories { 17 | maven { url = uri("https://jitpack.io") } 18 | } 19 | 20 | dependencies { 21 | implementation("com.google.protobuf:protobuf-java:3.14.0") 22 | implementation("com.github.theapache64:google-play-api:latest.version") 23 | } 24 | ``` 25 | 26 | 27 | ## ⌨️ Usage 28 | 29 | ```kotlin 30 | val username = "example@gmail.com" 31 | val password = "pass1234" 32 | 33 | // Logging in 34 | val account = Play.login(username, password) 35 | 36 | // Creating API using logged in account 37 | val api = Play.getApi(account) 38 | 39 | // Accessing API 40 | val appDetails = api.details(packageName) // to get all app details 41 | val downloadData = api.download("com.whatsapp") // to download APK 42 | 43 | // and much more... 44 | ``` 45 | 46 | ## 🥼 Run tests 47 | 48 | ```shell script 49 | ./gradlew test 50 | ``` 51 | 52 | ## ✍️ Author 53 | 54 | 👤 **theapache64** 55 | 56 | * Twitter: @theapache64 57 | * Email: theapache64@gmail.com 58 | 59 | This library is a combination of APIs collected from `raccoon4` and `playcrawler`. 60 | All credit goes to them. 61 | 62 | ## 🤝 Contributing 63 | 64 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any 65 | contributions you make are **greatly appreciated**. 66 | 67 | 1. Open an issue first to discuss what you would like to change. 68 | 1. Fork the Project 69 | 1. Create your feature branch (`git checkout -b feature/amazing-feature`) 70 | 1. Commit your changes (`git commit -m 'Add some amazing feature'`) 71 | 1. Push to the branch (`git push origin feature/amazing-feature`) 72 | 1. Open a pull request 73 | 74 | Please make sure to update tests as appropriate. 75 | 76 | ## ❤ Show your support 77 | 78 | Give a ⭐️ if this project helped you! 79 | 80 | 81 | Patron Link 82 | 83 | 84 | 85 | Buy Me A Coffee 86 | 87 | 88 | 89 | Donation 90 | 91 | 92 | ## 📝 License 93 | 94 | ``` 95 | Copyright © 2021 - theapache64 96 | 97 | Licensed under the Apache License, Version 2.0 (the "License"); 98 | you may not use this file except in compliance with the License. 99 | You may obtain a copy of the License at 100 | 101 | http://www.apache.org/licenses/LICENSE-2.0 102 | 103 | Unless required by applicable law or agreed to in writing, software 104 | distributed under the License is distributed on an "AS IS" BASIS, 105 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 106 | See the License for the specific language governing permissions and 107 | limitations under the License. 108 | ``` 109 | 110 | _This README was generated by [readgen](https://github.com/theapache64/readgen)_ ❤ 111 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'maven' 4 | id 'maven-publish' 5 | id 'org.jetbrains.kotlin.jvm' version '1.4.30' 6 | } 7 | 8 | group 'com.github.theapache64' 9 | version '0.0.9' 10 | 11 | repositories { 12 | jcenter() 13 | mavenCentral() 14 | } 15 | 16 | test { 17 | useJUnitPlatform() 18 | environment("PLAY_API_GOOGLE_USERNAME", System.getenv("PLAY_API_GOOGLE_USERNAME")) 19 | environment("PLAY_API_GOOGLE_PASSWORD", System.getenv("PLAY_API_GOOGLE_PASSWORD")) 20 | } 21 | 22 | 23 | dependencies { 24 | implementation "org.jetbrains.kotlin:kotlin-stdlib" 25 | 26 | // Akdeniz Deps 27 | implementation 'com.google.protobuf:protobuf-java:3.14.0' 28 | implementation 'org.apache.httpcomponents:httpclient:4.5.13' 29 | implementation 'org.apache.httpcomponents:httpcore:4.4.14' 30 | 31 | // Bouncy Castle Provider : The Bouncy Castle Crypto package is a Java implementation of cryptographic algorithms. 32 | // This jar contains JCE provider and lightweight API for the Bouncy Castle Cryptography 33 | // APIs for JDK 1.5 and up. 34 | implementation 'org.bouncycastle:bctls-jdk15on:1.57' 35 | 36 | // Kotlinx Coroutines Core : Coroutines support libraries for Kotlin 37 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3' 38 | implementation 'org.junit.jupiter:junit-jupiter:5.4.2' 39 | 40 | // Expekt : An assertion library for Kotlin 41 | testImplementation 'com.theapache64:expekt:0.0.1' 42 | } 43 | 44 | task sourcesJar(type: Jar, dependsOn: classes) { 45 | archiveClassifier.set('sources') 46 | from sourceSets.main.allSource 47 | } 48 | 49 | javadoc.failOnError = false 50 | task javadocJar(type: Jar, dependsOn: javadoc) { 51 | archiveClassifier.set('javadoc') 52 | from javadoc.destinationDir 53 | } 54 | 55 | artifacts { 56 | archives sourcesJar 57 | archives javadocJar 58 | } 59 | 60 | def pomConfig = { 61 | licenses { 62 | license { 63 | name "The Apache Software License, Version 2.0" 64 | url "http://www.apache.org/licenses/LICENSE-2.0.txt" 65 | distribution "repo" 66 | } 67 | } 68 | developers { 69 | developer { 70 | id "shifarshifz" 71 | name "shifarshifz" 72 | email "theapache64@gmail.com" 73 | } 74 | } 75 | 76 | scm { 77 | url "https://github.com/theapache64/google-play-api" 78 | } 79 | } 80 | 81 | publishing { 82 | publications { 83 | mavenPublication(MavenPublication) { 84 | from components.java 85 | artifact sourcesJar { 86 | classifier "sources" 87 | } 88 | artifact javadocJar { 89 | classifier "javadoc" 90 | } 91 | groupId 'com.github.theapache64' 92 | artifactId 'google-play-api' 93 | version "$version" 94 | pom.withXml { 95 | def root = asNode() 96 | root.appendNode('description', 'To access play store') 97 | root.appendNode('name', 'google-play-api') 98 | root.appendNode('url', 'https://github.com/theapache64/google-play-api') 99 | root.children().last() + pomConfig 100 | } 101 | } 102 | } 103 | } 104 | 105 | 106 | -------------------------------------------------------------------------------- /cover.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/google-play-api/cbac21a07efd9a17ea10e6ff293b22e27067af3a/cover.jpeg -------------------------------------------------------------------------------- /gpm.json: -------------------------------------------------------------------------------- 1 | { 2 | "added": [ 3 | { 4 | "id": 1, 5 | "type": "implementation", 6 | "installed_name": "bouncycastle", 7 | "gpm_dep": { 8 | "artifact_id": "bcprov-jdk15on", 9 | "default_type": "implementation", 10 | "docs": "https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15on", 11 | "get_from": "Central", 12 | "group_id": "org.bouncycastle", 13 | "name": "Bouncy Castle Provider", 14 | "description": "The Bouncy Castle Crypto package is a Java implementation of cryptographic algorithms. This jar contains JCE provider and lightweight API for the Bouncy Castle Cryptography APIs for JDK 1.5 and up." 15 | } 16 | }, 17 | { 18 | "id": 2, 19 | "type": "implementation", 20 | "installed_name": "junit", 21 | "gpm_dep": { 22 | "artifact_id": "junit", 23 | "default_type": "implementation", 24 | "docs": "https://mvnrepository.com/artifact/junit/junit", 25 | "get_from": "Central", 26 | "group_id": "junit", 27 | "name": "JUnit", 28 | "description": "JUnit is a unit testing framework for Java, created by Erich Gamma and Kent Beck." 29 | } 30 | }, 31 | { 32 | "id": 3, 33 | "type": "implementation", 34 | "installed_name": "coroutines-core", 35 | "gpm_dep": { 36 | "artifact_id": "kotlinx-coroutines-core", 37 | "default_type": "implementation", 38 | "docs": "https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core", 39 | "get_from": "Central", 40 | "group_id": "org.jetbrains.kotlinx", 41 | "name": "Kotlinx Coroutines Core", 42 | "description": "Coroutines support libraries for Kotlin" 43 | } 44 | }, 45 | { 46 | "id": 4, 47 | "type": "testImplementation", 48 | "installed_name": "expekt", 49 | "gpm_dep": { 50 | "artifact_id": "expekt", 51 | "default_type": "implementation", 52 | "docs": "https://mvnrepository.com/artifact/com.theapache64/expekt", 53 | "get_from": "JCenter", 54 | "group_id": "com.theapache64", 55 | "name": "Expekt", 56 | "description": "An assertion library for Kotlin" 57 | } 58 | } 59 | ] 60 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/google-play-api/cbac21a07efd9a17ea10e6ff293b22e27067af3a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'google-play-api' 2 | 3 | -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/DownloadData.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import java.security.InvalidAlgorithmParameterException; 6 | import java.security.InvalidKeyException; 7 | import java.security.NoSuchAlgorithmException; 8 | import java.security.NoSuchProviderException; 9 | import java.util.zip.GZIPInputStream; 10 | 11 | import javax.crypto.Cipher; 12 | import javax.crypto.CipherInputStream; 13 | import javax.crypto.NoSuchPaddingException; 14 | import javax.crypto.spec.IvParameterSpec; 15 | import javax.crypto.spec.SecretKeySpec; 16 | 17 | import com.akdeniz.googleplaycrawler.GooglePlay.AndroidAppDeliveryData; 18 | import com.akdeniz.googleplaycrawler.GooglePlay.HttpCookie; 19 | import com.akdeniz.googleplaycrawler.misc.Base64; 20 | 21 | public class DownloadData { 22 | 23 | private AndroidAppDeliveryData appDeliveryData; 24 | private String downloadUrl; 25 | private HttpCookie downloadAuthCookie; 26 | private GooglePlayAPI api; 27 | private long totalUncompressedSize; 28 | private long totalCompressedSize; 29 | private boolean compress; 30 | 31 | public DownloadData(GooglePlayAPI api, 32 | AndroidAppDeliveryData appDeliveryData) { 33 | this.appDeliveryData = appDeliveryData; 34 | this.api = api; 35 | this.downloadUrl = appDeliveryData.getDownloadUrl(); 36 | for (HttpCookie cookie : appDeliveryData.getDownloadAuthCookieList()) { 37 | this.downloadAuthCookie = cookie; 38 | } 39 | /* 40 | * this.totalSize = appDeliveryData.getDownloadSize(); for (int i = 0; i < 41 | * appDeliveryData.getAdditionalFileCount(); i++) { totalSize += 42 | * appDeliveryData.getAdditionalFile(i).getSize(); } 43 | */ 44 | setCompress(false); 45 | } 46 | 47 | public void setCompress(boolean c) { 48 | compress = c; 49 | this.totalUncompressedSize = appDeliveryData.getDownloadSize(); 50 | this.totalCompressedSize = appDeliveryData.getGzippedDownloadSize(); 51 | for (int i = 0; i < appDeliveryData.getAdditionalFileCount(); i++) { 52 | if (!appDeliveryData.getAdditionalFile(i).hasCompressedDownloadUrl()) { 53 | compress = false; 54 | } 55 | this.totalUncompressedSize += appDeliveryData.getAdditionalFile(i) 56 | .getSize(); 57 | this.totalCompressedSize += appDeliveryData.getAdditionalFile(i) 58 | .getCompressedSize(); 59 | } 60 | for (int i = 0; i < appDeliveryData.getSplitDeliveryDataCount(); i++) { 61 | if (!appDeliveryData.getSplitDeliveryData(i).hasGzippedDownloadUrl()) { 62 | break; 63 | } 64 | this.totalUncompressedSize += appDeliveryData.getSplitDeliveryData(i) 65 | .getDownloadSize(); 66 | this.totalCompressedSize += appDeliveryData.getSplitDeliveryData(i) 67 | .getGzippedDownloadSize(); 68 | } 69 | if (!appDeliveryData.hasGzippedDownloadUrl()) { 70 | compress = false; 71 | } 72 | } 73 | 74 | /** 75 | * Access the APK file 76 | * 77 | * @return an inputstream from which the app can be read (already processed 78 | * through crypto). 79 | * @throws NoSuchPaddingException 80 | * @throws NoSuchProviderException 81 | * @throws NoSuchAlgorithmException 82 | * @throws InvalidAlgorithmParameterException 83 | * @throws InvalidKeyException 84 | */ 85 | public InputStream openApp() throws IOException, NoSuchAlgorithmException, 86 | NoSuchProviderException, NoSuchPaddingException, InvalidKeyException, 87 | InvalidAlgorithmParameterException { 88 | InputStream ret = null; 89 | String tmp = null; 90 | if (downloadAuthCookie != null) { 91 | tmp = downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue(); 92 | } 93 | if (compress) { 94 | ret = new GZIPInputStream( 95 | api.executeDownload(appDeliveryData.getGzippedDownloadUrl(), tmp)); 96 | } 97 | else { 98 | ret = api.executeDownload(downloadUrl, tmp); 99 | } 100 | if (appDeliveryData.hasEncryptionParams()) { 101 | int version = ret.read(); 102 | if (version != 0) { 103 | throw new IOException("Unknown crypto container!"); 104 | } 105 | ret.skip(4); // Meta data 106 | byte[] iv = new byte[16]; 107 | ret.read(iv); 108 | byte[] encoded = appDeliveryData.getEncryptionParams().getEncryptionKey() 109 | .getBytes("UTF-8"); 110 | byte[] decoded = Base64.decode(encoded, Base64.DEFAULT); 111 | Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding", "SunJCE"); 112 | SecretKeySpec key = new SecretKeySpec(decoded, "AES"); 113 | cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv)); 114 | return new CipherInputStream(ret, cipher); 115 | } 116 | else { 117 | return ret; 118 | } 119 | } 120 | 121 | public long getAppSize() { 122 | return appDeliveryData.getDownloadSize(); 123 | } 124 | 125 | /** 126 | * Query the total downloadsize 127 | * 128 | * @return number of bytes to transfer. 129 | */ 130 | public long getTotalSize() { 131 | if (compress) { 132 | return totalUncompressedSize; 133 | } 134 | else { 135 | return totalUncompressedSize; 136 | } 137 | } 138 | 139 | /** 140 | * Access the first expansion 141 | * 142 | * @return a stream or null if there is no expansion. 143 | */ 144 | public InputStream openMainExpansion() throws IOException { 145 | if (appDeliveryData.getAdditionalFileCount() < 1) { 146 | return null; 147 | } 148 | if (compress) { 149 | String url = appDeliveryData.getAdditionalFile(0) 150 | .getCompressedDownloadUrl(); 151 | return new GZIPInputStream(api.executeDownload(url, 152 | downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue())); 153 | } 154 | else { 155 | String url = appDeliveryData.getAdditionalFile(0).getDownloadUrl(); 156 | return api.executeDownload(url, 157 | downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue()); 158 | } 159 | } 160 | 161 | public boolean hasMainExpansion() { 162 | return appDeliveryData.getAdditionalFileCount() > 0; 163 | } 164 | 165 | public int getMainFileVersion() { 166 | if (appDeliveryData.getAdditionalFileCount() > 0) { 167 | return appDeliveryData.getAdditionalFile(0).getVersionCode(); 168 | } 169 | return -1; 170 | } 171 | 172 | public long getMainSize() { 173 | return appDeliveryData.getAdditionalFile(0).getSize(); 174 | } 175 | 176 | /** 177 | * Access the second expansion 178 | * 179 | * @return a stream or null if there is no expansion. 180 | */ 181 | public InputStream openPatchExpansion() throws IOException { 182 | if (appDeliveryData.getAdditionalFileCount() < 2) { 183 | return null; 184 | } 185 | if (compress) { 186 | String url = appDeliveryData.getAdditionalFile(1) 187 | .getCompressedDownloadUrl(); 188 | return new GZIPInputStream(api.executeDownload(url, 189 | downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue())); 190 | } 191 | else { 192 | String url = appDeliveryData.getAdditionalFile(1).getDownloadUrl(); 193 | return api.executeDownload(url, 194 | downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue()); 195 | } 196 | } 197 | 198 | public boolean hasPatchExpansion() { 199 | return appDeliveryData.getAdditionalFileCount() > 1; 200 | } 201 | 202 | public long getPatchSize() { 203 | return appDeliveryData.getAdditionalFile(1).getSize(); 204 | } 205 | 206 | public int getPatchFileVersion() { 207 | if (appDeliveryData.getAdditionalFileCount() > 1) { 208 | return appDeliveryData.getAdditionalFile(1).getVersionCode(); 209 | } 210 | return -1; 211 | } 212 | 213 | public int getSplitCount() { 214 | return appDeliveryData.getSplitDeliveryDataCount(); 215 | } 216 | 217 | public InputStream openSplitDelivery(int n) throws IOException { 218 | if (getSplitCount() < 1) { 219 | return null; 220 | } 221 | if (compress) { 222 | String url = appDeliveryData.getSplitDeliveryData(n) 223 | .getGzippedDownloadUrl(); 224 | return new GZIPInputStream(api.executeDownload(url, 225 | downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue())); 226 | } 227 | else { 228 | String url = appDeliveryData.getSplitDeliveryData(n).getDownloadUrl(); 229 | return api.executeDownload(url, 230 | downloadAuthCookie.getName() + "=" + downloadAuthCookie.getValue()); 231 | } 232 | } 233 | 234 | public String toString() { 235 | return appDeliveryData.toString(); 236 | } 237 | 238 | public String getSplitId(int n) { 239 | if (getSplitCount() > 0) { 240 | return appDeliveryData.getSplitDeliveryData(n).getId(); 241 | } 242 | return null; 243 | } 244 | 245 | public long getSplitSize(int n) { 246 | if (getSplitCount() > 0) { 247 | return appDeliveryData.getSplitDeliveryData(n).getDownloadSize(); 248 | } 249 | return -1; 250 | } 251 | 252 | } 253 | -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/GooglePlayAPI.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import java.math.BigInteger; 6 | import java.security.Key; 7 | import java.security.KeyFactory; 8 | import java.security.MessageDigest; 9 | import java.security.PublicKey; 10 | import java.security.spec.RSAPublicKeySpec; 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | import java.util.Map; 14 | import java.util.PropertyResourceBundle; 15 | import java.util.ResourceBundle; 16 | 17 | import javax.crypto.Cipher; 18 | 19 | import org.apache.http.HttpEntity; 20 | import org.apache.http.HttpResponse; 21 | import org.apache.http.NameValuePair; 22 | import org.apache.http.client.ClientProtocolException; 23 | import org.apache.http.client.HttpClient; 24 | import org.apache.http.client.entity.UrlEncodedFormEntity; 25 | import org.apache.http.client.methods.HttpGet; 26 | import org.apache.http.client.methods.HttpPost; 27 | import org.apache.http.client.methods.HttpUriRequest; 28 | import org.apache.http.client.utils.URLEncodedUtils; 29 | import org.apache.http.conn.ClientConnectionManager; 30 | import org.apache.http.entity.ByteArrayEntity; 31 | import org.apache.http.impl.client.DefaultHttpClient; 32 | import org.apache.http.impl.conn.PoolingClientConnectionManager; 33 | import org.apache.http.impl.conn.SchemeRegistryFactory; 34 | import org.apache.http.message.BasicNameValuePair; 35 | 36 | import com.akdeniz.googleplaycrawler.GooglePlay.AndroidAppDeliveryData; 37 | import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinRequest; 38 | import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinResponse; 39 | //import com.akdeniz.googleplaycrawler.GooglePlay.BrowseResponse; 40 | import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsRequest; 41 | import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsRequest.Builder; 42 | import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsResponse; 43 | import com.akdeniz.googleplaycrawler.GooglePlay.BuyResponse; 44 | import com.akdeniz.googleplaycrawler.GooglePlay.DetailsResponse; 45 | import com.akdeniz.googleplaycrawler.GooglePlay.ListResponse; 46 | import com.akdeniz.googleplaycrawler.GooglePlay.ResponseWrapper; 47 | import com.akdeniz.googleplaycrawler.GooglePlay.ReviewResponse; 48 | import com.akdeniz.googleplaycrawler.GooglePlay.SearchResponse; 49 | import com.akdeniz.googleplaycrawler.GooglePlay.UploadDeviceConfigRequest; 50 | import com.akdeniz.googleplaycrawler.GooglePlay.UploadDeviceConfigResponse; 51 | import com.akdeniz.googleplaycrawler.misc.Base64; 52 | 53 | /** 54 | * This class provides 55 | * checkin, search, details, bulkDetails, browse, list and download 56 | * capabilities. It uses Apache Commons HttpClient for POST and GET 57 | * requests. 58 | * 59 | *

60 | * XXX : DO NOT call checkin, login and download consecutively. To allow 61 | * server to catch up, sleep for a while before download! (5 sec will do!) Also 62 | * it is recommended to call checkin once and use generated android-id for 63 | * further operations. 64 | *

65 | * 66 | * @author akdeniz 67 | * 68 | */ 69 | public class GooglePlayAPI { 70 | 71 | private static final String CHECKIN_URL = "https://android.clients.google.com/checkin"; 72 | private static final String URL_LOGIN = "https://android.clients.google.com/auth"; 73 | private static final String C2DM_REGISTER_URL = "https://android.clients.google.com/c2dm/register2"; 74 | private static final String FDFE_URL = "https://android.clients.google.com/fdfe/"; 75 | private static final String LIST_URL = FDFE_URL + "list"; 76 | private static final String BROWSE_URL = FDFE_URL + "browse"; 77 | private static final String DETAILS_URL = FDFE_URL + "details"; 78 | private static final String SEARCH_URL = FDFE_URL + "search"; 79 | private static final String BULKDETAILS_URL = FDFE_URL + "bulkDetails"; 80 | private static final String PURCHASE_URL = FDFE_URL + "purchase"; 81 | private static final String REVIEWS_URL = FDFE_URL + "rev"; 82 | private static final String UPLOADDEVICECONFIG_URL = FDFE_URL 83 | + "uploadDeviceConfig"; 84 | private static final String RECOMMENDATIONS_URL = FDFE_URL + "rec"; 85 | private static final String DELIVERY_URL = FDFE_URL + "delivery"; 86 | 87 | private static final String ACCOUNT_TYPE_HOSTED_OR_GOOGLE = "HOSTED_OR_GOOGLE"; 88 | 89 | public static enum REVIEW_SORT { 90 | NEWEST(0), HIGHRATING(1), HELPFUL(2); 91 | 92 | public int value; 93 | 94 | private REVIEW_SORT(int value) { 95 | this.value = value; 96 | } 97 | } 98 | 99 | public static enum RECOMMENDATION_TYPE { 100 | ALSO_VIEWED(1), ALSO_INSTALLED(2); 101 | 102 | public int value; 103 | 104 | private RECOMMENDATION_TYPE(int value) { 105 | this.value = value; 106 | } 107 | } 108 | 109 | private String token; 110 | private String androidID; 111 | private String email; 112 | private String password; 113 | private HttpClient client; 114 | private String securityToken; 115 | private String localization; 116 | private String useragent; 117 | 118 | /** 119 | * Default constructor. ANDROID ID and Authentication token must be supplied 120 | * before any other operation. 121 | */ 122 | public GooglePlayAPI() { 123 | } 124 | 125 | /** 126 | * Constructs a ready to login {@link GooglePlayAPI}. 127 | */ 128 | public GooglePlayAPI(String email, String password, String androidID) { 129 | this(email, password); 130 | this.setAndroidID(androidID); 131 | } 132 | 133 | /** 134 | * If this constructor is used, Android ID must be generated by calling 135 | * checkin() or set by using setAndroidID before 136 | * using other abilities. 137 | */ 138 | public GooglePlayAPI(String email, String password) { 139 | this.setEmail(email); 140 | this.password = password; 141 | setClient(new DefaultHttpClient(getConnectionManager())); 142 | // setUseragent("Android-Finsky/3.10.14 (api=3,versionCode=8016014,sdk=15,device=GT-I9300,hardware=aries,product=GT-I9300)"); 143 | // setUseragent("Android-Finsky/6.5.08.D-all (versionCode=80650800,sdk=24,device=dream2lte,hardware=dream2lte,product=dream2ltexx,build=NRD90M:user)"); 144 | setUseragent("Android-Finsky/13.1.32-all (versionCode=81313200,sdk=24,device=dream2lte,hardware=dream2lte,product=dream2ltexx,build=NRD90M:user)"); 145 | } 146 | 147 | /** 148 | * Connection manager to allow concurrent connections. 149 | * 150 | * @return {@link ClientConnectionManager} instance 151 | */ 152 | public static ClientConnectionManager getConnectionManager() { 153 | PoolingClientConnectionManager connManager = new PoolingClientConnectionManager( 154 | SchemeRegistryFactory.createDefault()); 155 | connManager.setMaxTotal(100); 156 | connManager.setDefaultMaxPerRoute(30); 157 | return connManager; 158 | } 159 | 160 | /** 161 | * Performs authentication on "ac2dm" service and match up android id, 162 | * security token and email by checking them in on this server. 163 | * 164 | * This function sets check-inded android ID and that can be taken either by 165 | * using getToken() or from returned 166 | * {@link AndroidCheckinResponse} instance. 167 | * 168 | */ 169 | public AndroidCheckinResponse checkin() throws Exception { 170 | 171 | // this first checkin is for generating android-id 172 | AndroidCheckinResponse checkinResponse = postCheckin(Utils 173 | .generateAndroidCheckinRequest().toByteArray()); 174 | this.setAndroidID(BigInteger.valueOf(checkinResponse.getGsfId()).toString( 175 | 16)); 176 | setSecurityToken((BigInteger.valueOf(checkinResponse.getSecurityToken()) 177 | .toString(16))); 178 | 179 | String c2dmAuth = loginAC2DM(); 180 | // login(); 181 | // String c2dmAuth= getToken(); 182 | 183 | AndroidCheckinRequest.Builder checkInbuilder = AndroidCheckinRequest 184 | .newBuilder(Utils.generateAndroidCheckinRequest()); 185 | 186 | AndroidCheckinRequest build = checkInbuilder 187 | .setId(new BigInteger(this.getAndroidID(), 16).longValue()) 188 | .setSecurityToken(new BigInteger(getSecurityToken(), 16).longValue()) 189 | .addAccountCookie("[" + getEmail() + "]").addAccountCookie(c2dmAuth) 190 | .build(); 191 | // this is the second checkin to match credentials with android-id 192 | return postCheckin(build.toByteArray()); 193 | } 194 | 195 | private static int readInt(byte[] bArr, int i) { 196 | return (((((bArr[i] & 255) << 24) | 0) | ((bArr[i + 1] & 255) << 16)) | ((bArr[i + 2] & 255) << 8)) 197 | | (bArr[i + 3] & 255); 198 | } 199 | 200 | public static PublicKey createKeyFromString(String str, byte[] bArr) { 201 | try { 202 | byte[] decode = Base64.decode(str, 0); 203 | int readInt = readInt(decode, 0); 204 | byte[] obj = new byte[readInt]; 205 | System.arraycopy(decode, 4, obj, 0, readInt); 206 | BigInteger bigInteger = new BigInteger(1, obj); 207 | int readInt2 = readInt(decode, readInt + 4); 208 | byte[] obj2 = new byte[readInt2]; 209 | System.arraycopy(decode, readInt + 8, obj2, 0, readInt2); 210 | BigInteger bigInteger2 = new BigInteger(1, obj2); 211 | decode = MessageDigest.getInstance("SHA-1").digest(decode); 212 | bArr[0] = (byte) 0; 213 | System.arraycopy(decode, 0, bArr, 1, 4); 214 | return KeyFactory.getInstance("RSA").generatePublic( 215 | new RSAPublicKeySpec(bigInteger, bigInteger2)); 216 | } 217 | catch (Throwable e) { 218 | throw new RuntimeException(e); 219 | } 220 | } 221 | 222 | private static String encryptString(String str) { 223 | int i = 0; 224 | ResourceBundle bundle = PropertyResourceBundle 225 | .getBundle("com.akdeniz.googleplaycrawler.crypt"); 226 | String string = bundle.getString("key"); 227 | 228 | byte[] obj = new byte[5]; 229 | Key createKeyFromString = createKeyFromString(string, obj); 230 | if (createKeyFromString == null) { 231 | return null; 232 | } 233 | try { 234 | Cipher instance = Cipher 235 | .getInstance("RSA/ECB/OAEPWITHSHA1ANDMGF1PADDING"); 236 | byte[] bytes = str.getBytes("UTF-8"); 237 | int length = ((bytes.length - 1) / 86) + 1; 238 | byte[] obj2 = new byte[(length * 133)]; 239 | while (i < length) { 240 | instance.init(1, createKeyFromString); 241 | byte[] doFinal = instance.doFinal(bytes, i * 86, 242 | i == length + -1 ? bytes.length - (i * 86) : 86); 243 | System.arraycopy(obj, 0, obj2, i * 133, obj.length); 244 | System.arraycopy(doFinal, 0, obj2, (i * 133) + obj.length, 245 | doFinal.length); 246 | i++; 247 | } 248 | return Base64.encodeToString(obj2, 10); 249 | } 250 | catch (Throwable e) { 251 | throw new RuntimeException(e); 252 | } 253 | } 254 | 255 | /** 256 | * Logins AC2DM server and returns authentication string. 257 | */ 258 | public String loginAC2DM() throws IOException { 259 | HttpEntity c2dmResponseEntity = executePost(URL_LOGIN, 260 | new String[][] { 261 | { "Email", this.getEmail() }, 262 | { "EncryptedPasswd", 263 | encryptString(this.getEmail() + "\u0000" + this.password) }, 264 | { "add_account", "1" }, { "service", "ac2dm" }, 265 | { "accountType", ACCOUNT_TYPE_HOSTED_OR_GOOGLE }, 266 | { "has_permission", "1" }, { "source", "android" }, 267 | { "app", "com.google.android.gsf" }, { "device_country", "us" }, 268 | { "device_country", "us" }, { "lang", "en" }, 269 | { "sdk_version", "16" }, }, null); 270 | 271 | Map c2dmAuth = Utils.parseResponse(new String(Utils 272 | .readAll(c2dmResponseEntity.getContent()))); 273 | return c2dmAuth.get("Auth"); 274 | 275 | } 276 | 277 | public Map c2dmRegister(String application, String sender) 278 | throws IOException { 279 | 280 | String c2dmAuth = loginAC2DM(); 281 | String[][] data = new String[][] { { "app", application }, 282 | { "sender", sender }, 283 | { "device", new BigInteger(this.getAndroidID(), 16).toString() } }; 284 | HttpEntity responseEntity = executePost(C2DM_REGISTER_URL, data, 285 | getHeaderParameters(c2dmAuth, null)); 286 | return Utils.parseResponse(new String(Utils.readAll(responseEntity 287 | .getContent()))); 288 | } 289 | 290 | /** 291 | * Equivalent of setToken. This function does not performs 292 | * authentication, it simply sets authentication token. 293 | */ 294 | public void login(String token) throws Exception { 295 | setToken(token); 296 | } 297 | 298 | /** 299 | * Authenticates on server with given email and password and sets 300 | * authentication token. This token can be used to login instead of using 301 | * email and password every time. 302 | */ 303 | public void login() throws Exception { 304 | /* 305 | * HttpEntity responseEntity = executePost(URL_LOGIN, new String[][] { { 306 | * "Email", this.getEmail() }, { "EncryptedPasswd", 307 | * encryptString(this.getEmail()+"\u0000"+this.password) }, { "service", 308 | * "androidmarket" }, { "add_account", "1"}, { "accountType", 309 | * ACCOUNT_TYPE_HOSTED_OR_GOOGLE }, { "has_permission", "1" }, { "source", 310 | * "android" }, { "androidId", this.getAndroidID() }, { "app", 311 | * "com.android.vending" }, { "device_country", "en" }, { "lang", "en" }, { 312 | * "sdk_version", "17" }, }, null); 313 | * 314 | * Map response = Utils.parseResponse(new 315 | * String(Utils.readAll(responseEntity .getContent()))); if 316 | * (response.containsKey("Auth")) { setToken(response.get("Auth")); } else { 317 | * throw new GooglePlayException("Authentication failed!"); } 318 | */ 319 | Identity ident = Identity.signIn(getClient(), getEmail(), password); 320 | setToken(ident.getAuthToken()); 321 | } 322 | 323 | /** 324 | * Equivalent of search(query, null, null) 325 | */ 326 | public SearchResponse search(String query) throws IOException { 327 | return search(query, null, null); 328 | } 329 | 330 | /** 331 | * Fetches a search results for given query. Offset and numberOfResult 332 | * parameters are optional and null can be passed! 333 | */ 334 | public SearchResponse search(String query, Integer offset, 335 | Integer numberOfResult) throws IOException { 336 | 337 | ResponseWrapper responseWrapper = executeGETRequest( 338 | SEARCH_URL, 339 | new String[][] { 340 | { "c", "3" }, 341 | { "q", query }, 342 | { "o", (offset == null) ? null : String.valueOf(offset) }, 343 | { 344 | "n", 345 | (numberOfResult == null) ? null : String 346 | .valueOf(numberOfResult) }, }); 347 | 348 | return responseWrapper.getPayload().getSearchResponse(); 349 | } 350 | 351 | public ResponseWrapper searchApp(String query) throws IOException { 352 | ResponseWrapper responseWrapper = executeGETRequest(SEARCH_URL, 353 | new String[][] { { "c", "3" }, { "q", query }, 354 | 355 | }); 356 | 357 | return responseWrapper; 358 | } 359 | 360 | public ResponseWrapper getList(String url) throws IOException { 361 | return executeGETRequest(FDFE_URL+url, null); 362 | } 363 | 364 | /** 365 | * Fetches detailed information about passed package name. If it is needed to 366 | * fetch information about more than one application, consider to use 367 | * bulkDetails. 368 | */ 369 | public DetailsResponse details(String packageName) throws IOException { 370 | ResponseWrapper responseWrapper = executeGETRequest(DETAILS_URL, 371 | new String[][] { { "doc", packageName }, }); 372 | 373 | return responseWrapper.getPayload().getDetailsResponse(); 374 | } 375 | 376 | /** Equivalent of details but bulky one! */ 377 | public BulkDetailsResponse bulkDetails(List packageNames) 378 | throws IOException { 379 | 380 | Builder bulkDetailsRequestBuilder = BulkDetailsRequest.newBuilder(); 381 | bulkDetailsRequestBuilder.addAllDocid(packageNames).setIncludeDetails(true).setIncludeChildDocs(true); 382 | 383 | ResponseWrapper responseWrapper = executePOSTRequest(BULKDETAILS_URL, 384 | bulkDetailsRequestBuilder.build().toByteArray(), 385 | "application/protobuf"); 386 | 387 | return responseWrapper.getPayload().getBulkDetailsResponse(); 388 | } 389 | 390 | /** 391 | * Fetches available categories * public BrowseResponse browse() throws 392 | * IOException { 393 | * 394 | * return browse(null, null); } 395 | * 396 | * public BrowseResponse browse(String categoryId, String subCategoryId) 397 | * throws IOException { 398 | * 399 | * ResponseWrapper responseWrapper = executeGETRequest(BROWSE_URL, new 400 | * String[][] { { "c", "3" }, { "cat", categoryId }, { "ctr", subCategoryId } 401 | * }); 402 | * 403 | * return responseWrapper.getPayload().getBrowseResponse(); }/* 404 | * 405 | * /** Equivalent of list(categoryId, null, null, null). It 406 | * fetches sub-categories of given category! 407 | */ 408 | public ListResponse list(String categoryId) throws IOException { 409 | return list(categoryId, null, null, null); 410 | } 411 | 412 | /** 413 | * Fetches applications within supplied category and sub-category. If 414 | * null is given for sub-category, it fetches sub-categories of 415 | * passed category. 416 | * 417 | * Default values for offset and numberOfResult are "0" and "20" respectively. 418 | * These values are determined by Google Play Store. 419 | */ 420 | public ListResponse list(String categoryId, String subCategoryId, 421 | Integer offset, Integer numberOfResult) throws IOException { 422 | ResponseWrapper responseWrapper = executeGETRequest( 423 | LIST_URL, 424 | new String[][] { 425 | { "c", "3" }, 426 | { "cat", categoryId }, 427 | { "ctr", subCategoryId }, 428 | { "o", (offset == null) ? null : String.valueOf(offset) }, 429 | { 430 | "n", 431 | (numberOfResult == null) ? null : String 432 | .valueOf(numberOfResult) }, }); 433 | 434 | return responseWrapper.getPayload().getListResponse(); 435 | } 436 | 437 | public ListResponse nextPage(String url) throws IOException { 438 | ResponseWrapper responseWrapper = executeGETRequest(FDFE_URL + url, null); 439 | return responseWrapper.getPayload().getListResponse(); 440 | } 441 | 442 | /** 443 | * Downloads given application package name, version and offer type. Version 444 | * code and offer type can be fetch by details interface. 445 | **/ 446 | public DownloadData download(String packageName, int versionCode, 447 | int offerType) throws IOException { 448 | 449 | BuyResponse buyResponse = purchase(packageName, versionCode, offerType); 450 | 451 | return new DownloadData(this, buyResponse.getPurchaseStatusResponse() 452 | .getAppDeliveryData()); 453 | 454 | } 455 | 456 | public DownloadData delivery(String packageName, int versionCode, 457 | int offerType) throws IOException { 458 | ResponseWrapper responseWrapper = executeGETRequest(DELIVERY_URL, 459 | new String[][] { { "ot", String.valueOf(offerType) }, 460 | { "doc", packageName }, { "vc", String.valueOf(versionCode) }, }); 461 | 462 | AndroidAppDeliveryData appDeliveryData = responseWrapper.getPayload() 463 | .getDeliveryResponse().getAppDeliveryData(); 464 | return new DownloadData(this, appDeliveryData); 465 | } 466 | 467 | public DownloadData purchaseAndDeliver(String packageName, int versionCode, 468 | int offerType) throws IOException { 469 | BuyResponse buyResponse = purchase(packageName, versionCode, offerType); 470 | AndroidAppDeliveryData ada = buyResponse.getPurchaseStatusResponse() 471 | .getAppDeliveryData(); 472 | if (ada.hasDownloadUrl() && ada.getDownloadAuthCookieCount() > 0) { 473 | // This is for backwards compatibility. 474 | return new DownloadData(this, ada); 475 | } 476 | return delivery(packageName, versionCode, offerType); 477 | } 478 | 479 | /** 480 | * Posts given check-in request content and returns 481 | * {@link AndroidCheckinResponse}. 482 | */ 483 | private AndroidCheckinResponse postCheckin(byte[] request) throws IOException { 484 | 485 | HttpEntity httpEntity = executePost(CHECKIN_URL, new ByteArrayEntity( 486 | request), new String[][] { 487 | { "User-Agent", "Android-Checkin/2.0 (generic JRO03E); gzip" }, 488 | { "Host", "android.clients.google.com" }, 489 | { "Content-Type", "application/x-protobuffer" } }); 490 | return AndroidCheckinResponse.parseFrom(httpEntity.getContent()); 491 | } 492 | 493 | /** 494 | * This function is used for fetching download url and donwload cookie, rather 495 | * than actual purchasing. 496 | */ 497 | private BuyResponse purchase(String packageName, int versionCode, 498 | int offerType) throws IOException { 499 | 500 | ResponseWrapper responseWrapper = executePOSTRequest(PURCHASE_URL, 501 | new String[][] { { "ot", String.valueOf(offerType) }, 502 | { "doc", packageName }, { "vc", String.valueOf(versionCode) }, }); 503 | 504 | return responseWrapper.getPayload().getBuyResponse(); 505 | } 506 | 507 | /** 508 | * Fetches url content by executing GET request with provided cookie string. 509 | */ 510 | public InputStream executeDownload(String url, String cookie) 511 | throws IOException { 512 | 513 | if (cookie!= null) { 514 | String[][] headerParams = new String[][] { 515 | { "Cookie", cookie }, 516 | { "User-Agent", 517 | "AndroidDownloadManager/4.1.1 (Linux; U; Android 4.1.1; Nexus S Build/JRO03E)" }, }; 518 | 519 | HttpEntity httpEntity = executeGet(url, null, headerParams); 520 | return httpEntity.getContent(); 521 | } 522 | else { 523 | String[][] headerParams = new String[][] { 524 | { "User-Agent", 525 | "AndroidDownloadManager/4.1.1 (Linux; U; Android 4.1.1; Nexus S Build/JRO03E)" }, }; 526 | 527 | HttpEntity httpEntity = executeGet(url, null, headerParams); 528 | return httpEntity.getContent(); 529 | } 530 | } 531 | 532 | /** 533 | * Fetches the reviews of given package name by sorting passed choice. 534 | * 535 | * Default values for offset and numberOfResult are "0" and "20" respectively. 536 | * These values are determined by Google Play Store. 537 | */ 538 | public ReviewResponse reviews(String packageName, REVIEW_SORT sort, 539 | Integer offset, Integer numberOfResult) throws IOException { 540 | ResponseWrapper responseWrapper = executeGETRequest( 541 | REVIEWS_URL, 542 | new String[][] { 543 | { "doc", packageName }, 544 | { "sort", (sort == null) ? null : String.valueOf(sort.value) }, 545 | { "o", (offset == null) ? null : String.valueOf(offset) }, 546 | { 547 | "n", 548 | (numberOfResult == null) ? null : String 549 | .valueOf(numberOfResult) } }); 550 | 551 | return responseWrapper.getPayload().getReviewResponse(); 552 | } 553 | 554 | /** 555 | * Uploads device configuration to google server so that can be seen from web 556 | * as a registered device!! 557 | * 558 | * @see https://play.google.com/store/account 559 | */ 560 | public UploadDeviceConfigResponse uploadDeviceConfig() throws Exception { 561 | 562 | UploadDeviceConfigRequest request = UploadDeviceConfigRequest.newBuilder() 563 | .setDeviceConfiguration(Utils.getDeviceConfigurationProto()) 564 | .setManufacturer("Samsung").build(); 565 | ResponseWrapper responseWrapper = executePOSTRequest( 566 | UPLOADDEVICECONFIG_URL, request.toByteArray(), "application/x-protobuf"); 567 | return responseWrapper.getPayload().getUploadDeviceConfigResponse(); 568 | } 569 | 570 | /** 571 | * Fetches the recommendations of given package name. 572 | * 573 | * Default values for offset and numberOfResult are "0" and "20" respectively. 574 | * These values are determined by Google Play Store. 575 | */ 576 | public ListResponse recommendations(String packageName, 577 | RECOMMENDATION_TYPE type, Integer offset, Integer numberOfResult) 578 | throws IOException { 579 | ResponseWrapper responseWrapper = executeGETRequest( 580 | RECOMMENDATIONS_URL, 581 | new String[][] { 582 | { "c", "3" }, 583 | { "doc", packageName }, 584 | { "rt", (type == null) ? null : String.valueOf(type.value) }, 585 | { "o", (offset == null) ? null : String.valueOf(offset) }, 586 | { 587 | "n", 588 | (numberOfResult == null) ? null : String 589 | .valueOf(numberOfResult) } }); 590 | 591 | return responseWrapper.getPayload().getListResponse(); 592 | } 593 | 594 | /* =======================Helper Functions====================== */ 595 | 596 | /** 597 | * Executes GET request and returns result as {@link ResponseWrapper}. 598 | * Standard header parameters will be used for request. 599 | * 600 | * @see getHeaderParameters 601 | * */ 602 | private ResponseWrapper executeGETRequest(String path, String[][] datapost) 603 | throws IOException { 604 | 605 | HttpEntity httpEntity = executeGet(path, datapost, 606 | getHeaderParameters(this.getToken(), null)); 607 | return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent()); 608 | 609 | } 610 | 611 | /** 612 | * Executes POST request and returns result as {@link ResponseWrapper}. 613 | * Standard header parameters will be used for request. 614 | * 615 | * @see getHeaderParameters 616 | * */ 617 | private ResponseWrapper executePOSTRequest(String path, String[][] datapost) 618 | throws IOException { 619 | 620 | HttpEntity httpEntity = executePost(path, datapost, 621 | getHeaderParameters(this.getToken(), null)); 622 | return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent()); 623 | 624 | } 625 | 626 | /** 627 | * Executes POST request and returns result as {@link ResponseWrapper}. 628 | * Content type can be specified for given byte array. 629 | */ 630 | private ResponseWrapper executePOSTRequest(String url, byte[] datapost, 631 | String contentType) throws IOException { 632 | 633 | HttpEntity httpEntity = executePost(url, new ByteArrayEntity(datapost), 634 | getHeaderParameters(this.getToken(), contentType)); 635 | return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent()); 636 | 637 | } 638 | 639 | /** 640 | * Executes POST request on given URL with POST parameters and header 641 | * parameters. 642 | */ 643 | private HttpEntity executePost(String url, String[][] postParams, 644 | String[][] headerParams) throws IOException { 645 | 646 | List formparams = new ArrayList(); 647 | 648 | for (String[] param : postParams) { 649 | if (param[0] != null && param[1] != null) { 650 | formparams.add(new BasicNameValuePair(param[0], param[1])); 651 | } 652 | } 653 | 654 | UrlEncodedFormEntity entity = new UrlEncodedFormEntity(formparams, "UTF-8"); 655 | 656 | return executePost(url, entity, headerParams); 657 | } 658 | 659 | /** 660 | * Executes POST request on given URL with {@link HttpEntity} typed POST 661 | * parameters and header parameters. 662 | */ 663 | private HttpEntity executePost(String url, HttpEntity postData, 664 | String[][] headerParams) throws IOException { 665 | HttpPost httppost = new HttpPost(url); 666 | 667 | if (headerParams != null) { 668 | for (String[] param : headerParams) { 669 | if (param[0] != null && param[1] != null) { 670 | httppost.setHeader(param[0], param[1]); 671 | } 672 | } 673 | } 674 | 675 | httppost.setEntity(postData); 676 | 677 | return executeHttpRequest(httppost); 678 | } 679 | 680 | /** 681 | * Executes GET request on given URL with GET parameters and header 682 | * parameters. 683 | */ 684 | private HttpEntity executeGet(String url, String[][] getParams, 685 | String[][] headerParams) throws IOException { 686 | 687 | if (getParams != null) { 688 | List formparams = new ArrayList(); 689 | 690 | for (String[] param : getParams) { 691 | if (param[0] != null && param[1] != null) { 692 | formparams.add(new BasicNameValuePair(param[0], param[1])); 693 | } 694 | } 695 | 696 | url = url + "?" + URLEncodedUtils.format(formparams, "UTF-8"); 697 | } 698 | 699 | HttpGet httpget = new HttpGet(url); 700 | 701 | if (headerParams != null) { 702 | for (String[] param : headerParams) { 703 | if (param[0] != null && param[1] != null) { 704 | httpget.setHeader(param[0], param[1]); 705 | } 706 | } 707 | } 708 | 709 | return executeHttpRequest(httpget); 710 | } 711 | 712 | /** Executes given GET/POST request */ 713 | private HttpEntity executeHttpRequest(HttpUriRequest request) 714 | throws ClientProtocolException, IOException { 715 | 716 | HttpResponse response = getClient().execute(request); 717 | 718 | if (response.getStatusLine().getStatusCode() != 200) { 719 | throw GooglePlayException.create(response); 720 | } 721 | 722 | return response.getEntity(); 723 | } 724 | 725 | /** 726 | * Gets header parameters for GET/POST requests. If no content type is given, 727 | * default one is used! 728 | */ 729 | private String[][] getHeaderParameters(String token, String contentType) { 730 | 731 | return new String[][] { 732 | { "Accept-Language", 733 | getLocalization() != null ? getLocalization() : "en-EN" }, 734 | { "Authorization", "GoogleLogin auth=" + token }, 735 | { "X-DFE-Enabled-Experiments", 736 | "cl:billing.select_add_instrument_by_default" }, 737 | { 738 | "X-DFE-Unsupported-Experiments", 739 | "nocache:billing.use_charging_poller,market_emails,buyer_currency,prod_baseline,checkin.set_asset_paid_app_field,shekel_test,content_ratings,buyer_currency_in_app,nocache:encrypted_apk,recent_changes" }, 740 | { "X-DFE-Device-Id", this.getAndroidID() }, 741 | { "X-DFE-Client-Id", "am-android-google" }, 742 | { "User-Agent", getUseragent() }, 743 | { "X-DFE-SmallestScreenWidthDp", "320" }, 744 | { "X-DFE-Filter-Level", "3" }, 745 | { "Host", "android.clients.google.com" }, 746 | { 747 | "Content-Type", 748 | (contentType != null) ? contentType 749 | : "application/x-www-form-urlencoded; charset=UTF-8" } }; 750 | } 751 | 752 | public String getToken() { 753 | return token; 754 | } 755 | 756 | public void setToken(String token) { 757 | this.token = token; 758 | } 759 | 760 | public String getAndroidID() { 761 | return androidID; 762 | } 763 | 764 | public void setAndroidID(String androidID) { 765 | this.androidID = androidID; 766 | } 767 | 768 | public String getSecurityToken() { 769 | return securityToken; 770 | } 771 | 772 | public void setSecurityToken(String securityToken) { 773 | this.securityToken = securityToken; 774 | } 775 | 776 | public HttpClient getClient() { 777 | return client; 778 | } 779 | 780 | /** 781 | * Sets {@link HttpClient} instance for internal usage of GooglePlayAPI. It is 782 | * important to note that this instance should allow concurrent connections. 783 | * 784 | * @see getConnectionManager 785 | * 786 | * @param client 787 | */ 788 | public void setClient(HttpClient client) { 789 | this.client = client; 790 | } 791 | 792 | public String getEmail() { 793 | return email; 794 | } 795 | 796 | public void setEmail(String email) { 797 | this.email = email; 798 | } 799 | 800 | public String getLocalization() { 801 | return localization; 802 | } 803 | 804 | /** 805 | * Localization string that will be used in each request to server. Using this 806 | * option you can fetch localized informations such as reviews and 807 | * descriptions. 808 | *

809 | * Note that changing this value has no affect on localized application list 810 | * that server provides. It depends on only your IP location. 811 | *

812 | * 813 | * @param localization 814 | * can be en-EN, en-US, tr-TR, fr-FR ... (default : en-EN) 815 | */ 816 | public void setLocalization(String localization) { 817 | this.localization = localization; 818 | } 819 | 820 | /** 821 | * @return the useragent 822 | */ 823 | public String getUseragent() { 824 | return useragent; 825 | } 826 | 827 | /** 828 | * @param useragent 829 | * the useragent to set 830 | */ 831 | public void setUseragent(String useragent) { 832 | this.useragent = useragent; 833 | } 834 | 835 | } 836 | -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/GooglePlayException.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import java.util.ArrayList; 6 | import java.util.Collections; 7 | import java.util.List; 8 | 9 | import org.apache.http.HttpResponse; 10 | 11 | import com.akdeniz.googleplaycrawler.GooglePlay.ResponseWrapper; 12 | import com.google.protobuf.CodedInputStream; 13 | import com.google.protobuf.WireFormat; 14 | 15 | public class GooglePlayException extends IOException { 16 | private static final long serialVersionUID = 1L; 17 | 18 | private final int httpStatus; 19 | 20 | public GooglePlayException(int httpStatus, String message) { 21 | super(message); 22 | this.httpStatus = httpStatus; 23 | } 24 | 25 | public int getHttpStatus() { 26 | return httpStatus; 27 | } 28 | 29 | public static GooglePlayException create(HttpResponse httpResponse) { 30 | String message = httpResponse.getStatusLine().getReasonPhrase(); 31 | 32 | // If the reponse contains a Protobuf response, retrieves the message from a 33 | // ResponseWrapper object 34 | InputStream content=null; 35 | try { 36 | content = httpResponse.getEntity().getContent(); 37 | if ("application/protobuf".equals(httpResponse.getEntity() 38 | .getContentType().getValue())) { 39 | ResponseWrapper rw = ResponseWrapper.parseFrom(content); 40 | if (rw.hasCommands() && rw.getCommands().hasDisplayErrorMessage()) { 41 | message = rw.getCommands().getDisplayErrorMessage(); 42 | } 43 | } 44 | } 45 | catch (Exception e) { 46 | } 47 | try { 48 | content.close(); 49 | } 50 | catch (IOException e) { 51 | } 52 | 53 | return new GooglePlayException( 54 | httpResponse.getStatusLine().getStatusCode(), message); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/Identity.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler; 2 | 3 | import com.akdeniz.googleplaycrawler.misc.Base64; 4 | import org.apache.http.HttpResponse; 5 | import org.apache.http.NameValuePair; 6 | import org.apache.http.client.ClientProtocolException; 7 | import org.apache.http.client.HttpClient; 8 | import org.apache.http.client.HttpResponseException; 9 | import org.apache.http.client.entity.UrlEncodedFormEntity; 10 | import org.apache.http.client.methods.HttpPost; 11 | import org.apache.http.message.BasicNameValuePair; 12 | 13 | import javax.crypto.BadPaddingException; 14 | import javax.crypto.Cipher; 15 | import javax.crypto.IllegalBlockSizeException; 16 | import javax.crypto.NoSuchPaddingException; 17 | import java.io.IOException; 18 | import java.io.InputStream; 19 | import java.io.UnsupportedEncodingException; 20 | import java.math.BigInteger; 21 | import java.security.*; 22 | import java.security.spec.InvalidKeySpecException; 23 | import java.security.spec.RSAPublicKeySpec; 24 | import java.util.*; 25 | 26 | /** 27 | * ClientLogin implementation. 28 | * 29 | * @author patrick 30 | * 31 | */ 32 | class Identity { 33 | 34 | private static final String LOGIN_URL = "https://android.clients.google.com/auth"; 35 | private static final String PUBKEY = "AAAAgMom/1a/v0lblO2Ubrt60J2gcuXSljGFQXgcyZWveWLEwo6prwgi3iJIZdodyhKZQrNWp5nKJ3srRXcUW+F1BD3baEVGcmEgqaLZUNBjm057pKRI16kB0YppeGx5qIQ5QjKzsR8ETQbKLNWgRY0QRNVz34kMJR3P/LgHax/6rmf5AAAAAwEAAQ=="; 36 | private static final String SERVICE = "androidmarket"; 37 | 38 | private String firstName; 39 | private String lastName; 40 | private String email; 41 | private String services; 42 | private String authToken; 43 | 44 | private Identity() { 45 | } 46 | 47 | /** 48 | * User's first name 49 | * 50 | * @return the first name, retrieved from Play 51 | */ 52 | public String getFirstName() { 53 | return firstName; 54 | } 55 | 56 | /** 57 | * User's last name 58 | * 59 | * @return the lastname, retrieved from Play 60 | */ 61 | public String getLastName() { 62 | return lastName; 63 | } 64 | 65 | /** 66 | * User's current email address 67 | * 68 | * @return the email address, retrieved from Play. 69 | */ 70 | public String getEmail() { 71 | return email; 72 | } 73 | 74 | /** 75 | * List the services, the user is clear for. 76 | * 77 | * @return list of service names. Potentially empty, never null 78 | */ 79 | public List listServices() { 80 | ArrayList ret = new ArrayList(); 81 | if (services != null) { 82 | String[] tmp = services.split(" *, *"); 83 | for (String s : tmp) { 84 | ret.add(s); 85 | } 86 | } 87 | return ret; 88 | } 89 | 90 | /** 91 | * Get the session cookie 92 | * 93 | * @return a token that can be used for getting access. 94 | */ 95 | public String getAuthToken() { 96 | return authToken; 97 | } 98 | 99 | /** 100 | * Sing into Play 101 | * 102 | * @param req 103 | * parameter object with username, password and locale. 104 | * @return new instance 105 | * @throws BadAuthenticationException 106 | * @throws ClientProtocolException 107 | * @throws HttpResponseException 108 | * @throws IOException 109 | */ 110 | public static Identity signIn(HttpClient client, String uid, String pwd) 111 | throws ClientProtocolException, 112 | HttpResponseException, IOException { 113 | 114 | 115 | 116 | Locale loc = Locale.getDefault(); 117 | String epwd = null; 118 | try { 119 | epwd = encryptString(uid + "\u0000" + pwd); 120 | } 121 | catch (Exception e) { 122 | // Should not happen unless the user is in a country with an embargo on 123 | // cryptography. In which case, we are screwed anyway. 124 | throw new RuntimeException("Could not encrypt password", e); 125 | } 126 | 127 | List params = new ArrayList(); 128 | params.add(new BasicNameValuePair("Email", uid)); 129 | params.add(new BasicNameValuePair("EncryptedPasswd", epwd)); 130 | params.add(new BasicNameValuePair("service", SERVICE)); 131 | params.add(new BasicNameValuePair("add_account", "1")); 132 | params.add(new BasicNameValuePair("sdk_version", "16")); 133 | params.add(new BasicNameValuePair("accountType", "HOSTED_OR_GOOGLE")); 134 | params.add(new BasicNameValuePair("hasPermission", "1")); 135 | params.add(new BasicNameValuePair("source", "android")); 136 | params.add(new BasicNameValuePair("app", "com.android.vending")); 137 | if (loc != null) { 138 | params.add(new BasicNameValuePair("device_country", loc.getLanguage())); 139 | params.add(new BasicNameValuePair("lang", loc.getLanguage())); 140 | } 141 | 142 | Map map = doPost(client, params); 143 | Identity ret = new Identity(); 144 | ret.firstName = map.get("firstName"); 145 | ret.lastName = map.get("lastName"); 146 | ret.email = map.get("Email"); 147 | ret.services = map.get("services"); 148 | ret.authToken = map.get("Auth"); 149 | String tok = map.get("Token"); 150 | 151 | if (tok != null && tok.length() > 0) { 152 | // Since mid Oct/2017, "Token" must be sent back if account details 153 | // (first-, lastname, services) are requested by "add_account". 154 | // Otherwise the "Auth" cookie will have a short TTL. 155 | params = new ArrayList(); 156 | params.add(new BasicNameValuePair("Authorization", "GoogleLogin auth=" 157 | + map.get("Auth"))); 158 | params.add(new BasicNameValuePair("Token", tok)); 159 | params.add(new BasicNameValuePair("token_request_options", "CAA4AQ==")); 160 | params.add(new BasicNameValuePair("service", SERVICE)); 161 | params.add(new BasicNameValuePair("accountType", "HOSTED_OR_GOOGLE")); 162 | params.add(new BasicNameValuePair("app", "com.android.vending")); 163 | map = doPost(client, params); 164 | ret.authToken = map.get("Auth"); 165 | } 166 | return ret; 167 | } 168 | 169 | private static Map doPost(HttpClient client, 170 | List params) throws ClientProtocolException, 171 | IOException { 172 | HttpPost httppost = new HttpPost(LOGIN_URL); 173 | httppost.setEntity(new UrlEncodedFormEntity(params, "UTF-8")); 174 | HttpResponse response = client.execute(httppost); 175 | Map map = parseContent(response.getEntity().getContent()); 176 | if (response.getStatusLine().getStatusCode() == 200) { 177 | return map; 178 | } 179 | 180 | if (map.containsKey("Error")) { 181 | throw new ClientProtocolException(map.get("Error")); 182 | } 183 | throw new HttpResponseException(response.getStatusLine().getStatusCode(), 184 | response.getStatusLine().getReasonPhrase()); 185 | } 186 | 187 | private static Map parseContent(InputStream in) 188 | throws IOException { 189 | HashMap ret = new HashMap(); 190 | int k = 0; 191 | boolean value = false; // Simple state machine. 192 | StringBuilder key = new StringBuilder(); 193 | StringBuilder val = new StringBuilder(); 194 | while (true) { 195 | k = in.read(); 196 | if (k == -1) { // EOF 197 | ret.put(key.toString(), val.toString()); 198 | break; 199 | } 200 | if (k == '=') { // Skip symbol; toggle state 201 | value = true; 202 | continue; 203 | } 204 | if (k == '\n') { // End of value -> commit key/value pair 205 | value = false; 206 | ret.put(key.toString(), val.toString()); 207 | key.setLength(0); 208 | val.setLength(0); 209 | continue; 210 | } 211 | if (k == '\r') { // Skip line end symbol. 212 | continue; 213 | } 214 | if (value) { // Depending on state either file into key or value 215 | val.append((char) k); 216 | } 217 | else { 218 | key.append((char) k); 219 | } 220 | } 221 | return ret; 222 | } 223 | 224 | private static String encryptString(String str) 225 | throws NoSuchAlgorithmException, InvalidKeySpecException, 226 | NoSuchPaddingException, UnsupportedEncodingException, 227 | InvalidKeyException, IllegalBlockSizeException, BadPaddingException { 228 | 229 | int i = 0; 230 | 231 | byte[] obj = new byte[5]; 232 | Key createKeyFromString = createKeyFromString(PUBKEY, obj); 233 | if (createKeyFromString == null) { 234 | return null; 235 | } 236 | 237 | Cipher instance = Cipher.getInstance("RSA/ECB/OAEPWITHSHA1ANDMGF1PADDING"); 238 | byte[] bytes = str.getBytes("UTF-8"); 239 | int length = ((bytes.length - 1) / 86) + 1; 240 | byte[] obj2 = new byte[(length * 133)]; 241 | while (i < length) { 242 | instance.init(1, createKeyFromString); 243 | byte[] doFinal = instance.doFinal(bytes, i * 86, 244 | i == length + -1 ? bytes.length - (i * 86) : 86); 245 | System.arraycopy(obj, 0, obj2, i * 133, obj.length); 246 | System 247 | .arraycopy(doFinal, 0, obj2, (i * 133) + obj.length, doFinal.length); 248 | i++; 249 | } 250 | return Base64.encodeToString(obj2, 10); 251 | } 252 | 253 | private static PublicKey createKeyFromString(String str, byte[] bArr) 254 | throws NoSuchAlgorithmException, InvalidKeySpecException { 255 | byte[] decode = Base64.decode(str, 0); 256 | int readInt = readInt(decode, 0); 257 | byte[] obj = new byte[readInt]; 258 | System.arraycopy(decode, 4, obj, 0, readInt); 259 | BigInteger bigInteger = new BigInteger(1, obj); 260 | int readInt2 = readInt(decode, readInt + 4); 261 | byte[] obj2 = new byte[readInt2]; 262 | System.arraycopy(decode, readInt + 8, obj2, 0, readInt2); 263 | BigInteger bigInteger2 = new BigInteger(1, obj2); 264 | decode = MessageDigest.getInstance("SHA-1").digest(decode); 265 | bArr[0] = (byte) 0; 266 | System.arraycopy(decode, 0, bArr, 1, 4); 267 | return KeyFactory.getInstance("RSA").generatePublic( 268 | new RSAPublicKeySpec(bigInteger, bigInteger2)); 269 | 270 | } 271 | 272 | private static int readInt(byte[] bArr, int i) { 273 | return (((((bArr[i] & 255) << 24) | 0) | ((bArr[i + 1] & 255) << 16)) | ((bArr[i + 2] & 255) << 8)) 274 | | (bArr[i + 3] & 255); 275 | } 276 | 277 | @Override 278 | public String toString() { 279 | StringBuilder sb = new StringBuilder(); 280 | sb.append(firstName); 281 | sb.append(' '); 282 | sb.append(lastName); 283 | sb.append('<'); 284 | sb.append(email); 285 | sb.append('>'); 286 | sb.append('\t'); 287 | sb.append(services); 288 | sb.append('\t'); 289 | sb.append(authToken); 290 | return sb.toString(); 291 | } 292 | 293 | } 294 | -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/Utils.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler; 2 | 3 | import java.io.ByteArrayOutputStream; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | import java.math.BigInteger; 7 | import java.security.KeyFactory; 8 | import java.security.KeyManagementException; 9 | import java.security.MessageDigest; 10 | import java.security.NoSuchAlgorithmException; 11 | import java.security.PublicKey; 12 | import java.security.spec.RSAPublicKeySpec; 13 | import java.util.Arrays; 14 | import java.util.Date; 15 | import java.util.HashMap; 16 | import java.util.Map; 17 | import java.util.StringTokenizer; 18 | 19 | import javax.crypto.Cipher; 20 | import javax.net.ssl.SSLContext; 21 | import javax.net.ssl.TrustManager; 22 | 23 | import org.apache.http.conn.scheme.Scheme; 24 | import org.apache.http.conn.ssl.SSLSocketFactory; 25 | 26 | import com.akdeniz.googleplaycrawler.GooglePlay.AndroidBuildProto; 27 | import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinProto; 28 | import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinRequest; 29 | import com.akdeniz.googleplaycrawler.GooglePlay.DeviceConfigurationProto; 30 | import com.akdeniz.googleplaycrawler.misc.Base64; 31 | import com.akdeniz.googleplaycrawler.misc.DummyX509TrustManager; 32 | 33 | /** 34 | * 35 | * @author akdeniz 36 | * 37 | */ 38 | public class Utils { 39 | 40 | private static final String GOOGLE_PUBLIC_KEY = "AAAAgMom/1a/v0lblO2Ubrt60J2gcuXSljGFQXgcyZWveWLEwo6prwgi3" 41 | + "iJIZdodyhKZQrNWp5nKJ3srRXcUW+F1BD3baEVGcmEgqaLZUNBjm057pKRI16kB0YppeGx5qIQ5QjKzsR8ETQbKLNWgRY0Q" 42 | + "RNVz34kMJR3P/LgHax/6rmf5AAAAAwEAAQ=="; 43 | 44 | /** 45 | * Parses key-value response into map. 46 | */ 47 | public static Map parseResponse(String response) { 48 | 49 | Map keyValueMap = new HashMap(); 50 | StringTokenizer st = new StringTokenizer(response, "\n\r"); 51 | 52 | while (st.hasMoreTokens()) { 53 | String[] keyValue = st.nextToken().split("="); 54 | // Note to self: the original implementation did not check for array length. 55 | // Nowadays it is possible to get keys with empty values and therefore an 56 | // ArrayIndexOutOfBoundsException. Since we are only interested in "Auth=", 57 | // we can simply ignore everything that's not a k/v pair. 58 | if (keyValue.length==2) { 59 | keyValueMap.put(keyValue[0], keyValue[1]); 60 | } 61 | else { 62 | //System.err.println("Utils.paseResponse: "+response); 63 | } 64 | } 65 | 66 | return keyValueMap; 67 | } 68 | 69 | private static PublicKey createKey(byte[] keyByteArray) throws Exception { 70 | 71 | int modulusLength = readInt(keyByteArray, 0); 72 | byte[] modulusByteArray = new byte[modulusLength]; 73 | System.arraycopy(keyByteArray, 4, modulusByteArray, 0, modulusLength); 74 | BigInteger modulus = new BigInteger(1, modulusByteArray); 75 | 76 | int exponentLength = readInt(keyByteArray, modulusLength + 4); 77 | byte[] exponentByteArray = new byte[exponentLength]; 78 | System.arraycopy(keyByteArray, modulusLength + 8, exponentByteArray, 0, exponentLength); 79 | BigInteger publicExponent = new BigInteger(1, exponentByteArray); 80 | 81 | return KeyFactory.getInstance("RSA").generatePublic(new RSAPublicKeySpec(modulus, publicExponent)); 82 | } 83 | 84 | /** 85 | * Encrypts given string with Google Public Key. 86 | * 87 | */ 88 | public static String encryptString(String str2Encrypt) throws Exception { 89 | 90 | byte[] keyByteArray = Base64.decode(GOOGLE_PUBLIC_KEY, Base64.DEFAULT); 91 | 92 | byte[] header = new byte[5]; 93 | byte[] digest = MessageDigest.getInstance("SHA-1").digest(keyByteArray); 94 | header[0] = 0; 95 | System.arraycopy(digest, 0, header, 1, 4); 96 | 97 | PublicKey publicKey = createKey(keyByteArray); 98 | 99 | Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWITHSHA1ANDMGF1PADDING"); 100 | byte[] bytes2Encrypt = str2Encrypt.getBytes("UTF-8"); 101 | int len = ((bytes2Encrypt.length - 1) / 86) + 1; 102 | byte[] cryptedBytes = new byte[len * 133]; 103 | 104 | for (int j = 0; j < len; j++) { 105 | cipher.init(1, publicKey); 106 | byte[] arrayOfByte4 = cipher.doFinal(bytes2Encrypt, j * 86, (bytes2Encrypt.length - j * 86)); 107 | System.arraycopy(header, 0, cryptedBytes, j * 133, header.length); 108 | System.arraycopy(arrayOfByte4, 0, cryptedBytes, j * 133 + header.length, arrayOfByte4.length); 109 | } 110 | return Base64.encodeToString(cryptedBytes, 10); 111 | } 112 | 113 | private static int readInt(byte[] data, int offset) { 114 | return (0xFF & data[offset]) << 24 | (0xFF & data[(offset + 1)]) << 16 | (0xFF & data[(offset + 2)]) << 8 115 | | (0xFF & data[(offset + 3)]); 116 | } 117 | 118 | /** 119 | * Reads all contents of the input stream. 120 | * 121 | */ 122 | public static byte[] readAll(InputStream inputStream) throws IOException { 123 | 124 | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 125 | byte[] buffer = new byte[1024]; 126 | 127 | int k = 0; 128 | for (; (k = inputStream.read(buffer)) != -1;) { 129 | outputStream.write(buffer, 0, k); 130 | } 131 | 132 | return outputStream.toByteArray(); 133 | } 134 | 135 | public static String bytesToHex(byte[] bytes) { 136 | final char[] hexArray = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' }; 137 | char[] hexChars = new char[bytes.length * 2]; 138 | int v; 139 | for (int j = 0; j < bytes.length; j++) { 140 | v = bytes[j] & 0xFF; 141 | hexChars[j * 2] = hexArray[v >>> 4]; 142 | hexChars[j * 2 + 1] = hexArray[v & 0x0F]; 143 | } 144 | return new String(hexChars); 145 | } 146 | 147 | public static byte[] hexToBytes(String s) { 148 | int len = s.length(); 149 | byte[] data = new byte[len / 2]; 150 | for (int i = 0; i < len; i += 2) { 151 | data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16)); 152 | } 153 | return data; 154 | } 155 | 156 | public static Scheme getMockedScheme() throws NoSuchAlgorithmException, KeyManagementException { 157 | SSLContext sslcontext = SSLContext.getInstance("TLS"); 158 | 159 | sslcontext.init(null, new TrustManager[] { new DummyX509TrustManager() }, null); 160 | SSLSocketFactory sf = new SSLSocketFactory(sslcontext); 161 | Scheme https = new Scheme("https", 443, sf); 162 | 163 | return https; 164 | } 165 | 166 | /** 167 | * Generates android checkin request with properties of "Galaxy S3". 168 | * 169 | * http://www.glbenchmark.com/phonedetails.jsp?benchmark=glpro25&D=Samsung 172 | * +GT-I9300+Galaxy+S+III&testgroup=system 173 | */ 174 | public static AndroidCheckinRequest generateAndroidCheckinRequest() { 175 | 176 | return AndroidCheckinRequest 177 | .newBuilder() 178 | .setId(0) 179 | .setCheckin( 180 | AndroidCheckinProto 181 | .newBuilder() 182 | .setBuild( 183 | AndroidBuildProto.newBuilder() 184 | .setId("samsung/dream2ltexx/dream2lte:7.0/NRD90M/G955FXXU1AQGB:user/release-keys") 185 | .setProduct("dream2ltexx").setCarrier("Google").setRadio("I9300XXALF2") 186 | .setBootloader("PRIMELA03").setClient("android-google") 187 | .setTimestamp(new Date().getTime() / 1000).setGoogleServices(16).setDevice("dream2lte") 188 | .setSdkVersion(26).setModel("SM-G955F").setManufacturer("Samsung") 189 | .setBuildProduct("dream2ltexx").setOtaInstalled(false)).setLastCheckinMsec(0) 190 | .setCellOperator("310260").setSimOperator("310260").setRoaming("mobile-notroaming") 191 | .setUserNumber(0)).setLocale("en_US").setTimeZone("Europe/Berlin").setVersion(3) 192 | .setDeviceConfiguration(getDeviceConfigurationProto()).setFragment(0).build(); 193 | } 194 | 195 | public static DeviceConfigurationProto getDeviceConfigurationProto() { 196 | return DeviceConfigurationProto 197 | .newBuilder() 198 | .setTouchScreen(3) 199 | .setKeyboard(1) 200 | .setNavigation(1) 201 | .setScreenLayout(2) 202 | .setHasHardKeyboard(false) 203 | .setHasFiveWayNavigation(false) 204 | .setScreenDensity(320) 205 | .setGlEsVersion(196610) 206 | .addAllSystemSharedLibrary( 207 | Arrays.asList("android.test.runner", "com.android.future.usb.accessory", "com.android.location.provider", 208 | "com.android.nfc_extras", "com.google.android.maps", "com.google.android.media.effects", 209 | "com.google.widevine.software.drm", "javax.obex")) 210 | .addAllSystemAvailableFeature( 211 | Arrays.asList("android.hardware.bluetooth","android.hardware.bluetooth_le", "android.hardware.camera", 212 | "android.hardware.camera.autofocus", "android.hardware.camera.flash", 213 | "android.hardware.camera.front", "android.hardware.faketouch", "android.hardware.location", 214 | "android.hardware.location.gps", "android.hardware.location.network", 215 | "android.hardware.microphone", "android.hardware.nfc", "android.hardware.screen.landscape", 216 | "android.hardware.screen.portrait", "android.hardware.sensor.accelerometer", 217 | "android.hardware.sensor.barometer", "android.hardware.sensor.compass", 218 | "android.hardware.sensor.gyroscope", "android.hardware.sensor.light", 219 | "android.hardware.sensor.proximity", "android.hardware.telephony", 220 | "android.hardware.telephony.gsm", "android.hardware.touchscreen", 221 | "android.hardware.touchscreen.multitouch", "android.hardware.touchscreen.multitouch.distinct", 222 | "android.hardware.touchscreen.multitouch.jazzhand", "android.hardware.usb.accessory", 223 | "android.hardware.usb.host", "android.hardware.wifi", "android.hardware.wifi.direct", 224 | "android.software.live_wallpaper", "android.software.sip", "android.software.sip.voip", 225 | "com.cyanogenmod.android", "com.cyanogenmod.nfc.enhanced", "org.cyanogenmod.theme", 226 | "com.google.android.feature.GOOGLE_BUILD", "com.nxp.mifare", "com.tmobile.software.themes")) 227 | .addAllNativePlatform(Arrays.asList("armeabi-v7a", "armeabi")) 228 | .setScreenWidth(720) 229 | .setScreenHeight(1184) 230 | .addAllSystemSupportedLocale( 231 | Arrays.asList("af", "af_ZA", "am", "am_ET", "ar", "ar_EG", "bg", "bg_BG", "ca", "ca_ES", "cs", "cs_CZ", 232 | "da", "da_DK", "de", "de_AT", "de_CH", "de_DE", "de_LI", "el", "el_GR", "en", "en_AU", "en_CA", 233 | "en_GB", "en_NZ", "en_SG", "en_US", "es", "es_ES", "es_US", "fa", "fa_IR", "fi", "fi_FI", "fr", 234 | "fr_BE", "fr_CA", "fr_CH", "fr_FR", "hi", "hi_IN", "hr", "hr_HR", "hu", "hu_HU", "in", "in_ID", 235 | "it", "it_CH", "it_IT", "iw", "iw_IL", "ja", "ja_JP", "ko", "ko_KR", "lt", "lt_LT", "lv", 236 | "lv_LV", "ms", "ms_MY", "nb", "nb_NO", "nl", "nl_BE", "nl_NL", "pl", "pl_PL", "pt", "pt_BR", 237 | "pt_PT", "rm", "rm_CH", "ro", "ro_RO", "ru", "ru_RU", "sk", "sk_SK", "sl", "sl_SI", "sr", 238 | "sr_RS", "sv", "sv_SE", "sw", "sw_TZ", "th", "th_TH", "tl", "tl_PH", "tr", "tr_TR", "ug", 239 | "ug_CN", "uk", "uk_UA", "vi", "vi_VN", "zh_CN", "zh_TW", "zu", "zu_ZA")) 240 | .addAllGlExtension( 241 | Arrays.asList("GL_EXT_debug_marker", "GL_EXT_discard_framebuffer", "GL_EXT_multi_draw_arrays", 242 | "GL_EXT_shader_texture_lod", "GL_EXT_texture_format_BGRA8888", 243 | "GL_IMG_multisampled_render_to_texture", "GL_IMG_program_binary", "GL_IMG_read_format", 244 | "GL_IMG_shader_binary", "GL_IMG_texture_compression_pvrtc", "GL_IMG_texture_format_BGRA8888", 245 | "GL_IMG_texture_npot", "GL_IMG_vertex_array_object", "GL_OES_EGL_image", 246 | "GL_OES_EGL_image_external", "GL_OES_blend_equation_separate", "GL_OES_blend_func_separate", 247 | "GL_OES_blend_subtract", "GL_OES_byte_coordinates", "GL_OES_compressed_ETC1_RGB8_texture", 248 | "GL_OES_compressed_paletted_texture", "GL_OES_depth24", "GL_OES_depth_texture", 249 | "GL_OES_draw_texture", "GL_OES_egl_sync", "GL_OES_element_index_uint", 250 | "GL_OES_extended_matrix_palette", "GL_OES_fixed_point", "GL_OES_fragment_precision_high", 251 | "GL_OES_framebuffer_object", "GL_OES_get_program_binary", "GL_OES_mapbuffer", 252 | "GL_OES_matrix_get", "GL_OES_matrix_palette", "GL_OES_packed_depth_stencil", 253 | "GL_OES_point_size_array", "GL_OES_point_sprite", "GL_OES_query_matrix", "GL_OES_read_format", 254 | "GL_OES_required_internalformat", "GL_OES_rgb8_rgba8", "GL_OES_single_precision", 255 | "GL_OES_standard_derivatives", "GL_OES_stencil8", "GL_OES_stencil_wrap", 256 | "GL_OES_texture_cube_map", "GL_OES_texture_env_crossbar", "GL_OES_texture_float", 257 | "GL_OES_texture_half_float", "GL_OES_texture_mirrored_repeat", "GL_OES_vertex_array_object", 258 | "GL_OES_vertex_half_float")).build(); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/misc/Base64.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler.misc; 2 | /* 3 | * Copyright (C) 2010 The Android Open Source Project 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import java.io.UnsupportedEncodingException; 19 | 20 | /** 21 | * Utilities for encoding and decoding the Base64 representation of 22 | * binary data. See RFCs 2045 and 3548. 25 | */ 26 | public class Base64 { 27 | /** 28 | * Default values for encoder/decoder flags. 29 | */ 30 | public static final int DEFAULT = 0; 31 | 32 | /** 33 | * Encoder flag bit to omit the padding '=' characters at the end 34 | * of the output (if any). 35 | */ 36 | public static final int NO_PADDING = 1; 37 | 38 | /** 39 | * Encoder flag bit to omit all line terminators (i.e., the output 40 | * will be on one long line). 41 | */ 42 | public static final int NO_WRAP = 2; 43 | 44 | /** 45 | * Encoder flag bit to indicate lines should be terminated with a 46 | * CRLF pair instead of just an LF. Has no effect if {@code 47 | * NO_WRAP} is specified as well. 48 | */ 49 | public static final int CRLF = 4; 50 | 51 | /** 52 | * Encoder/decoder flag bit to indicate using the "URL and 53 | * filename safe" variant of Base64 (see RFC 3548 section 4) where 54 | * {@code -} and {@code _} are used in place of {@code +} and 55 | * {@code /}. 56 | */ 57 | public static final int URL_SAFE = 8; 58 | 59 | /** 60 | * Flag to pass to {@link Base64OutputStream} to indicate that it 61 | * should not close the output stream it is wrapping when it 62 | * itself is closed. 63 | */ 64 | public static final int NO_CLOSE = 16; 65 | 66 | // -------------------------------------------------------- 67 | // shared code 68 | // -------------------------------------------------------- 69 | 70 | /* package */ static abstract class Coder { 71 | public byte[] output; 72 | public int op; 73 | 74 | /** 75 | * Encode/decode another block of input data. this.output is 76 | * provided by the caller, and must be big enough to hold all 77 | * the coded data. On exit, this.opwill be set to the length 78 | * of the coded data. 79 | * 80 | * @param finish true if this is the final call to process for 81 | * this object. Will finalize the coder state and 82 | * include any final bytes in the output. 83 | * 84 | * @return true if the input so far is good; false if some 85 | * error has been detected in the input stream.. 86 | */ 87 | public abstract boolean process(byte[] input, int offset, int len, boolean finish); 88 | 89 | /** 90 | * @return the maximum number of bytes a call to process() 91 | * could produce for the given number of input bytes. This may 92 | * be an overestimate. 93 | */ 94 | public abstract int maxOutputSize(int len); 95 | } 96 | 97 | // -------------------------------------------------------- 98 | // decoding 99 | // -------------------------------------------------------- 100 | 101 | /** 102 | * Decode the Base64-encoded data in input and return the data in 103 | * a new byte array. 104 | * 105 | *

The padding '=' characters at the end are considered optional, but 106 | * if any are present, there must be the correct number of them. 107 | * 108 | * @param str the input String to decode, which is converted to 109 | * bytes using the default charset 110 | * @param flags controls certain features of the decoded output. 111 | * Pass {@code DEFAULT} to decode standard Base64. 112 | * 113 | * @throws IllegalArgumentException if the input contains 114 | * incorrect padding 115 | */ 116 | public static byte[] decode(String str, int flags) { 117 | return decode(str.getBytes(), flags); 118 | } 119 | 120 | /** 121 | * Decode the Base64-encoded data in input and return the data in 122 | * a new byte array. 123 | * 124 | *

The padding '=' characters at the end are considered optional, but 125 | * if any are present, there must be the correct number of them. 126 | * 127 | * @param input the input array to decode 128 | * @param flags controls certain features of the decoded output. 129 | * Pass {@code DEFAULT} to decode standard Base64. 130 | * 131 | * @throws IllegalArgumentException if the input contains 132 | * incorrect padding 133 | */ 134 | public static byte[] decode(byte[] input, int flags) { 135 | return decode(input, 0, input.length, flags); 136 | } 137 | 138 | /** 139 | * Decode the Base64-encoded data in input and return the data in 140 | * a new byte array. 141 | * 142 | *

The padding '=' characters at the end are considered optional, but 143 | * if any are present, there must be the correct number of them. 144 | * 145 | * @param input the data to decode 146 | * @param offset the position within the input array at which to start 147 | * @param len the number of bytes of input to decode 148 | * @param flags controls certain features of the decoded output. 149 | * Pass {@code DEFAULT} to decode standard Base64. 150 | * 151 | * @throws IllegalArgumentException if the input contains 152 | * incorrect padding 153 | */ 154 | public static byte[] decode(byte[] input, int offset, int len, int flags) { 155 | // Allocate space for the most data the input could represent. 156 | // (It could contain less if it contains whitespace, etc.) 157 | Decoder decoder = new Decoder(flags, new byte[len*3/4]); 158 | 159 | if (!decoder.process(input, offset, len, true)) { 160 | throw new IllegalArgumentException("bad base-64"); 161 | } 162 | 163 | // Maybe we got lucky and allocated exactly enough output space. 164 | if (decoder.op == decoder.output.length) { 165 | return decoder.output; 166 | } 167 | 168 | // Need to shorten the array, so allocate a new one of the 169 | // right size and copy. 170 | byte[] temp = new byte[decoder.op]; 171 | System.arraycopy(decoder.output, 0, temp, 0, decoder.op); 172 | return temp; 173 | } 174 | 175 | /* package */ static class Decoder extends Coder { 176 | /** 177 | * Lookup table for turning bytes into their position in the 178 | * Base64 alphabet. 179 | */ 180 | private static final int DECODE[] = { 181 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 182 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 183 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 184 | 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, 185 | -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 186 | 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, 187 | -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 188 | 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, 189 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 190 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 191 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 192 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 193 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 194 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 195 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 196 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 197 | }; 198 | 199 | /** 200 | * Decode lookup table for the "web safe" variant (RFC 3548 201 | * sec. 4) where - and _ replace + and /. 202 | */ 203 | private static final int DECODE_WEBSAFE[] = { 204 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 205 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 206 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, 207 | 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, 208 | -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 209 | 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, 210 | -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 211 | 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, 212 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 213 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 214 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 215 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 216 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 217 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 218 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 219 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 220 | }; 221 | 222 | /** Non-data values in the DECODE arrays. */ 223 | private static final int SKIP = -1; 224 | private static final int EQUALS = -2; 225 | 226 | /** 227 | * States 0-3 are reading through the next input tuple. 228 | * State 4 is having read one '=' and expecting exactly 229 | * one more. 230 | * State 5 is expecting no more data or padding characters 231 | * in the input. 232 | * State 6 is the error state; an error has been detected 233 | * in the input and no future input can "fix" it. 234 | */ 235 | private int state; // state number (0 to 6) 236 | private int value; 237 | 238 | final private int[] alphabet; 239 | 240 | public Decoder(int flags, byte[] output) { 241 | this.output = output; 242 | 243 | alphabet = ((flags & URL_SAFE) == 0) ? DECODE : DECODE_WEBSAFE; 244 | state = 0; 245 | value = 0; 246 | } 247 | 248 | /** 249 | * @return an overestimate for the number of bytes {@code 250 | * len} bytes could decode to. 251 | */ 252 | public int maxOutputSize(int len) { 253 | return len * 3/4 + 10; 254 | } 255 | 256 | /** 257 | * Decode another block of input data. 258 | * 259 | * @return true if the state machine is still healthy. false if 260 | * bad base-64 data has been detected in the input stream. 261 | */ 262 | public boolean process(byte[] input, int offset, int len, boolean finish) { 263 | if (this.state == 6) return false; 264 | 265 | int p = offset; 266 | len += offset; 267 | 268 | // Using local variables makes the decoder about 12% 269 | // faster than if we manipulate the member variables in 270 | // the loop. (Even alphabet makes a measurable 271 | // difference, which is somewhat surprising to me since 272 | // the member variable is final.) 273 | int state = this.state; 274 | int value = this.value; 275 | int op = 0; 276 | final byte[] output = this.output; 277 | final int[] alphabet = this.alphabet; 278 | 279 | while (p < len) { 280 | // Try the fast path: we're starting a new tuple and the 281 | // next four bytes of the input stream are all data 282 | // bytes. This corresponds to going through states 283 | // 0-1-2-3-0. We expect to use this method for most of 284 | // the data. 285 | // 286 | // If any of the next four bytes of input are non-data 287 | // (whitespace, etc.), value will end up negative. (All 288 | // the non-data values in decode are small negative 289 | // numbers, so shifting any of them up and or'ing them 290 | // together will result in a value with its top bit set.) 291 | // 292 | // You can remove this whole block and the output should 293 | // be the same, just slower. 294 | if (state == 0) { 295 | while (p+4 <= len && 296 | (value = ((alphabet[input[p] & 0xff] << 18) | 297 | (alphabet[input[p+1] & 0xff] << 12) | 298 | (alphabet[input[p+2] & 0xff] << 6) | 299 | (alphabet[input[p+3] & 0xff]))) >= 0) { 300 | output[op+2] = (byte) value; 301 | output[op+1] = (byte) (value >> 8); 302 | output[op] = (byte) (value >> 16); 303 | op += 3; 304 | p += 4; 305 | } 306 | if (p >= len) break; 307 | } 308 | 309 | // The fast path isn't available -- either we've read a 310 | // partial tuple, or the next four input bytes aren't all 311 | // data, or whatever. Fall back to the slower state 312 | // machine implementation. 313 | 314 | int d = alphabet[input[p++] & 0xff]; 315 | 316 | switch (state) { 317 | case 0: 318 | if (d >= 0) { 319 | value = d; 320 | ++state; 321 | } else if (d != SKIP) { 322 | this.state = 6; 323 | return false; 324 | } 325 | break; 326 | 327 | case 1: 328 | if (d >= 0) { 329 | value = (value << 6) | d; 330 | ++state; 331 | } else if (d != SKIP) { 332 | this.state = 6; 333 | return false; 334 | } 335 | break; 336 | 337 | case 2: 338 | if (d >= 0) { 339 | value = (value << 6) | d; 340 | ++state; 341 | } else if (d == EQUALS) { 342 | // Emit the last (partial) output tuple; 343 | // expect exactly one more padding character. 344 | output[op++] = (byte) (value >> 4); 345 | state = 4; 346 | } else if (d != SKIP) { 347 | this.state = 6; 348 | return false; 349 | } 350 | break; 351 | 352 | case 3: 353 | if (d >= 0) { 354 | // Emit the output triple and return to state 0. 355 | value = (value << 6) | d; 356 | output[op+2] = (byte) value; 357 | output[op+1] = (byte) (value >> 8); 358 | output[op] = (byte) (value >> 16); 359 | op += 3; 360 | state = 0; 361 | } else if (d == EQUALS) { 362 | // Emit the last (partial) output tuple; 363 | // expect no further data or padding characters. 364 | output[op+1] = (byte) (value >> 2); 365 | output[op] = (byte) (value >> 10); 366 | op += 2; 367 | state = 5; 368 | } else if (d != SKIP) { 369 | this.state = 6; 370 | return false; 371 | } 372 | break; 373 | 374 | case 4: 375 | if (d == EQUALS) { 376 | ++state; 377 | } else if (d != SKIP) { 378 | this.state = 6; 379 | return false; 380 | } 381 | break; 382 | 383 | case 5: 384 | if (d != SKIP) { 385 | this.state = 6; 386 | return false; 387 | } 388 | break; 389 | } 390 | } 391 | 392 | if (!finish) { 393 | // We're out of input, but a future call could provide 394 | // more. 395 | this.state = state; 396 | this.value = value; 397 | this.op = op; 398 | return true; 399 | } 400 | 401 | // Done reading input. Now figure out where we are left in 402 | // the state machine and finish up. 403 | 404 | switch (state) { 405 | case 0: 406 | // Output length is a multiple of three. Fine. 407 | break; 408 | case 1: 409 | // Read one extra input byte, which isn't enough to 410 | // make another output byte. Illegal. 411 | this.state = 6; 412 | return false; 413 | case 2: 414 | // Read two extra input bytes, enough to emit 1 more 415 | // output byte. Fine. 416 | output[op++] = (byte) (value >> 4); 417 | break; 418 | case 3: 419 | // Read three extra input bytes, enough to emit 2 more 420 | // output bytes. Fine. 421 | output[op++] = (byte) (value >> 10); 422 | output[op++] = (byte) (value >> 2); 423 | break; 424 | case 4: 425 | // Read one padding '=' when we expected 2. Illegal. 426 | this.state = 6; 427 | return false; 428 | case 5: 429 | // Read all the padding '='s we expected and no more. 430 | // Fine. 431 | break; 432 | } 433 | 434 | this.state = state; 435 | this.op = op; 436 | return true; 437 | } 438 | } 439 | 440 | // -------------------------------------------------------- 441 | // encoding 442 | // -------------------------------------------------------- 443 | 444 | /** 445 | * Base64-encode the given data and return a newly allocated 446 | * String with the result. 447 | * 448 | * @param input the data to encode 449 | * @param flags controls certain features of the encoded output. 450 | * Passing {@code DEFAULT} results in output that 451 | * adheres to RFC 2045. 452 | */ 453 | public static String encodeToString(byte[] input, int flags) { 454 | try { 455 | return new String(encode(input, flags), "US-ASCII"); 456 | } catch (UnsupportedEncodingException e) { 457 | // US-ASCII is guaranteed to be available. 458 | throw new AssertionError(e); 459 | } 460 | } 461 | 462 | /** 463 | * Base64-encode the given data and return a newly allocated 464 | * String with the result. 465 | * 466 | * @param input the data to encode 467 | * @param offset the position within the input array at which to 468 | * start 469 | * @param len the number of bytes of input to encode 470 | * @param flags controls certain features of the encoded output. 471 | * Passing {@code DEFAULT} results in output that 472 | * adheres to RFC 2045. 473 | */ 474 | public static String encodeToString(byte[] input, int offset, int len, int flags) { 475 | try { 476 | return new String(encode(input, offset, len, flags), "US-ASCII"); 477 | } catch (UnsupportedEncodingException e) { 478 | // US-ASCII is guaranteed to be available. 479 | throw new AssertionError(e); 480 | } 481 | } 482 | 483 | /** 484 | * Base64-encode the given data and return a newly allocated 485 | * byte[] with the result. 486 | * 487 | * @param input the data to encode 488 | * @param flags controls certain features of the encoded output. 489 | * Passing {@code DEFAULT} results in output that 490 | * adheres to RFC 2045. 491 | */ 492 | public static byte[] encode(byte[] input, int flags) { 493 | return encode(input, 0, input.length, flags); 494 | } 495 | 496 | /** 497 | * Base64-encode the given data and return a newly allocated 498 | * byte[] with the result. 499 | * 500 | * @param input the data to encode 501 | * @param offset the position within the input array at which to 502 | * start 503 | * @param len the number of bytes of input to encode 504 | * @param flags controls certain features of the encoded output. 505 | * Passing {@code DEFAULT} results in output that 506 | * adheres to RFC 2045. 507 | */ 508 | public static byte[] encode(byte[] input, int offset, int len, int flags) { 509 | Encoder encoder = new Encoder(flags, null); 510 | 511 | // Compute the exact length of the array we will produce. 512 | int output_len = len / 3 * 4; 513 | 514 | // Account for the tail of the data and the padding bytes, if any. 515 | if (encoder.do_padding) { 516 | if (len % 3 > 0) { 517 | output_len += 4; 518 | } 519 | } else { 520 | switch (len % 3) { 521 | case 0: break; 522 | case 1: output_len += 2; break; 523 | case 2: output_len += 3; break; 524 | } 525 | } 526 | 527 | // Account for the newlines, if any. 528 | if (encoder.do_newline && len > 0) { 529 | output_len += (((len-1) / (3 * Encoder.LINE_GROUPS)) + 1) * 530 | (encoder.do_cr ? 2 : 1); 531 | } 532 | 533 | encoder.output = new byte[output_len]; 534 | encoder.process(input, offset, len, true); 535 | 536 | assert encoder.op == output_len; 537 | 538 | return encoder.output; 539 | } 540 | 541 | /* package */ static class Encoder extends Coder { 542 | /** 543 | * Emit a new line every this many output tuples. Corresponds to 544 | * a 76-character line length (the maximum allowable according to 545 | * RFC 2045). 546 | */ 547 | public static final int LINE_GROUPS = 19; 548 | 549 | /** 550 | * Lookup table for turning Base64 alphabet positions (6 bits) 551 | * into output bytes. 552 | */ 553 | private static final byte ENCODE[] = { 554 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 555 | 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 556 | 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 557 | 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/', 558 | }; 559 | 560 | /** 561 | * Lookup table for turning Base64 alphabet positions (6 bits) 562 | * into output bytes. 563 | */ 564 | private static final byte ENCODE_WEBSAFE[] = { 565 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 566 | 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 567 | 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 568 | 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_', 569 | }; 570 | 571 | final private byte[] tail; 572 | /* package */ int tailLen; 573 | private int count; 574 | 575 | final public boolean do_padding; 576 | final public boolean do_newline; 577 | final public boolean do_cr; 578 | final private byte[] alphabet; 579 | 580 | public Encoder(int flags, byte[] output) { 581 | this.output = output; 582 | 583 | do_padding = (flags & NO_PADDING) == 0; 584 | do_newline = (flags & NO_WRAP) == 0; 585 | do_cr = (flags & CRLF) != 0; 586 | alphabet = ((flags & URL_SAFE) == 0) ? ENCODE : ENCODE_WEBSAFE; 587 | 588 | tail = new byte[2]; 589 | tailLen = 0; 590 | 591 | count = do_newline ? LINE_GROUPS : -1; 592 | } 593 | 594 | /** 595 | * @return an overestimate for the number of bytes {@code 596 | * len} bytes could encode to. 597 | */ 598 | public int maxOutputSize(int len) { 599 | return len * 8/5 + 10; 600 | } 601 | 602 | public boolean process(byte[] input, int offset, int len, boolean finish) { 603 | // Using local variables makes the encoder about 9% faster. 604 | final byte[] alphabet = this.alphabet; 605 | final byte[] output = this.output; 606 | int op = 0; 607 | int count = this.count; 608 | 609 | int p = offset; 610 | len += offset; 611 | int v = -1; 612 | 613 | // First we need to concatenate the tail of the previous call 614 | // with any input bytes available now and see if we can empty 615 | // the tail. 616 | 617 | switch (tailLen) { 618 | case 0: 619 | // There was no tail. 620 | break; 621 | 622 | case 1: 623 | if (p+2 <= len) { 624 | // A 1-byte tail with at least 2 bytes of 625 | // input available now. 626 | v = ((tail[0] & 0xff) << 16) | 627 | ((input[p++] & 0xff) << 8) | 628 | (input[p++] & 0xff); 629 | tailLen = 0; 630 | }; 631 | break; 632 | 633 | case 2: 634 | if (p+1 <= len) { 635 | // A 2-byte tail with at least 1 byte of input. 636 | v = ((tail[0] & 0xff) << 16) | 637 | ((tail[1] & 0xff) << 8) | 638 | (input[p++] & 0xff); 639 | tailLen = 0; 640 | } 641 | break; 642 | } 643 | 644 | if (v != -1) { 645 | output[op++] = alphabet[(v >> 18) & 0x3f]; 646 | output[op++] = alphabet[(v >> 12) & 0x3f]; 647 | output[op++] = alphabet[(v >> 6) & 0x3f]; 648 | output[op++] = alphabet[v & 0x3f]; 649 | if (--count == 0) { 650 | if (do_cr) output[op++] = '\r'; 651 | output[op++] = '\n'; 652 | count = LINE_GROUPS; 653 | } 654 | } 655 | 656 | // At this point either there is no tail, or there are fewer 657 | // than 3 bytes of input available. 658 | 659 | // The main loop, turning 3 input bytes into 4 output bytes on 660 | // each iteration. 661 | while (p+3 <= len) { 662 | v = ((input[p] & 0xff) << 16) | 663 | ((input[p+1] & 0xff) << 8) | 664 | (input[p+2] & 0xff); 665 | output[op] = alphabet[(v >> 18) & 0x3f]; 666 | output[op+1] = alphabet[(v >> 12) & 0x3f]; 667 | output[op+2] = alphabet[(v >> 6) & 0x3f]; 668 | output[op+3] = alphabet[v & 0x3f]; 669 | p += 3; 670 | op += 4; 671 | if (--count == 0) { 672 | if (do_cr) output[op++] = '\r'; 673 | output[op++] = '\n'; 674 | count = LINE_GROUPS; 675 | } 676 | } 677 | 678 | if (finish) { 679 | // Finish up the tail of the input. Note that we need to 680 | // consume any bytes in tail before any bytes 681 | // remaining in input; there should be at most two bytes 682 | // total. 683 | 684 | if (p-tailLen == len-1) { 685 | int t = 0; 686 | v = ((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 4; 687 | tailLen -= t; 688 | output[op++] = alphabet[(v >> 6) & 0x3f]; 689 | output[op++] = alphabet[v & 0x3f]; 690 | if (do_padding) { 691 | output[op++] = '='; 692 | output[op++] = '='; 693 | } 694 | if (do_newline) { 695 | if (do_cr) output[op++] = '\r'; 696 | output[op++] = '\n'; 697 | } 698 | } else if (p-tailLen == len-2) { 699 | int t = 0; 700 | v = (((tailLen > 1 ? tail[t++] : input[p++]) & 0xff) << 10) | 701 | (((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 2); 702 | tailLen -= t; 703 | output[op++] = alphabet[(v >> 12) & 0x3f]; 704 | output[op++] = alphabet[(v >> 6) & 0x3f]; 705 | output[op++] = alphabet[v & 0x3f]; 706 | if (do_padding) { 707 | output[op++] = '='; 708 | } 709 | if (do_newline) { 710 | if (do_cr) output[op++] = '\r'; 711 | output[op++] = '\n'; 712 | } 713 | } else if (do_newline && op > 0 && count != LINE_GROUPS) { 714 | if (do_cr) output[op++] = '\r'; 715 | output[op++] = '\n'; 716 | } 717 | 718 | assert tailLen == 0; 719 | assert p == len; 720 | } else { 721 | // Save the leftovers in tail to be consumed on the next 722 | // call to encodeInternal. 723 | 724 | if (p == len-1) { 725 | tail[tailLen++] = input[p]; 726 | } else if (p == len-2) { 727 | tail[tailLen++] = input[p]; 728 | tail[tailLen++] = input[p+1]; 729 | } 730 | } 731 | 732 | this.op = op; 733 | this.count = count; 734 | 735 | return true; 736 | } 737 | } 738 | 739 | private Base64() { } // don't instantiate 740 | } 741 | -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/misc/DummyX509TrustManager.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler.misc; 2 | 3 | import java.security.cert.CertificateException; 4 | import java.security.cert.X509Certificate; 5 | 6 | import javax.net.ssl.X509TrustManager; 7 | 8 | /** 9 | * Dummy trust manager that accepts all certificates. 10 | * 11 | * @author akdeniz 12 | */ 13 | public class DummyX509TrustManager implements X509TrustManager { 14 | 15 | public X509Certificate[] getAcceptedIssuers() { 16 | return null; 17 | } 18 | 19 | public void checkServerTrusted(X509Certificate[] paramArrayOfX509Certificate, String paramString) 20 | throws CertificateException { 21 | } 22 | 23 | public void checkClientTrusted(X509Certificate[] paramArrayOfX509Certificate, String paramString) 24 | throws CertificateException { 25 | } 26 | }; -------------------------------------------------------------------------------- /src/main/java/com/akdeniz/googleplaycrawler/misc/HexDumpEncoder.java: -------------------------------------------------------------------------------- 1 | package com.akdeniz.googleplaycrawler.misc; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.io.ByteArrayOutputStream; 5 | import java.io.InputStream; 6 | import java.io.PrintStream; 7 | import java.io.OutputStream; 8 | import java.io.IOException; 9 | import java.nio.ByteBuffer; 10 | 11 | /** 12 | * This class encodes a buffer into the classic: "Hexadecimal Dump" format of 13 | * the past. It is useful for analyzing the contents of binary buffers. 14 | * The format produced is as follows: 15 | *

 16 |  * xxxx: 00 11 22 33 44 55 66 77   88 99 aa bb cc dd ee ff ................
 17 |  * 
18 | * Where xxxx is the offset into the buffer in 16 byte chunks, followed 19 | * by ascii coded hexadecimal bytes followed by the ASCII representation of 20 | * the bytes or '.' if they are not valid bytes. 21 | * 22 | * @author Chuck McManis 23 | */ 24 | 25 | public class HexDumpEncoder { 26 | 27 | protected PrintStream pStream; 28 | 29 | private int offset; 30 | private int thisLineLength; 31 | private int currentByte; 32 | private byte thisLine[] = new byte[16]; 33 | 34 | static void hexDigit(PrintStream p, byte x) { 35 | char c; 36 | 37 | c = (char) ((x >> 4) & 0xf); 38 | if (c > 9) 39 | c = (char) ((c-10) + 'A'); 40 | else 41 | c = (char)(c + '0'); 42 | p.write(c); 43 | c = (char) (x & 0xf); 44 | if (c > 9) 45 | c = (char)((c-10) + 'A'); 46 | else 47 | c = (char)(c + '0'); 48 | p.write(c); 49 | } 50 | 51 | protected int bytesPerAtom() { 52 | return (1); 53 | } 54 | 55 | protected int bytesPerLine() { 56 | return (16); 57 | } 58 | 59 | /** 60 | * Encode the prefix for the entire buffer. By default is simply 61 | * opens the PrintStream for use by the other functions. 62 | */ 63 | protected void encodeBufferPrefix(OutputStream aStream) throws IOException { 64 | offset = 0; 65 | pStream = new PrintStream(aStream); 66 | } 67 | 68 | protected void encodeLinePrefix(OutputStream o, int len) throws IOException { 69 | hexDigit(pStream, (byte)((offset >>> 8) & 0xff)); 70 | hexDigit(pStream, (byte)(offset & 0xff)); 71 | pStream.print(": "); 72 | currentByte = 0; 73 | thisLineLength = len; 74 | } 75 | 76 | protected void encodeAtom(OutputStream o, byte buf[], int off, int len) throws IOException { 77 | thisLine[currentByte] = buf[off]; 78 | hexDigit(pStream, buf[off]); 79 | pStream.print(" "); 80 | currentByte++; 81 | if (currentByte == 8) 82 | pStream.print(" "); 83 | } 84 | 85 | protected void encodeLineSuffix(OutputStream o) throws IOException { 86 | if (thisLineLength < 16) { 87 | for (int i = thisLineLength; i < 16; i++) { 88 | pStream.print(" "); 89 | if (i == 7) 90 | pStream.print(" "); 91 | } 92 | } 93 | pStream.print(" "); 94 | for (int i = 0; i < thisLineLength; i++) { 95 | if ((thisLine[i] < ' ') || (thisLine[i] > 'z')) { 96 | pStream.print("."); 97 | } else { 98 | pStream.write(thisLine[i]); 99 | } 100 | } 101 | pStream.println(); 102 | offset += thisLineLength; 103 | } 104 | 105 | 106 | /** 107 | * Encode bytes from the input stream, and write them as text characters 108 | * to the output stream. This method will run until it exhausts the 109 | * input stream, but does not print the line suffix for a final 110 | * line that is shorter than bytesPerLine(). 111 | */ 112 | public void encode(InputStream inStream, OutputStream outStream) 113 | throws IOException { 114 | int j; 115 | int numBytes; 116 | byte tmpbuffer[] = new byte[bytesPerLine()]; 117 | 118 | encodeBufferPrefix(outStream); 119 | 120 | while (true) { 121 | numBytes = readFully(inStream, tmpbuffer); 122 | if (numBytes == 0) { 123 | break; 124 | } 125 | encodeLinePrefix(outStream, numBytes); 126 | for (j = 0; j < numBytes; j += bytesPerAtom()) { 127 | 128 | if ((j + bytesPerAtom()) <= numBytes) { 129 | encodeAtom(outStream, tmpbuffer, j, bytesPerAtom()); 130 | } else { 131 | encodeAtom(outStream, tmpbuffer, j, (numBytes)- j); 132 | } 133 | } 134 | encodeLineSuffix(outStream); 135 | } 136 | encodeBufferSuffix(outStream); 137 | } 138 | 139 | /** 140 | * Encode the buffer in aBuffer and write the encoded 141 | * result to the OutputStream aStream. 142 | */ 143 | public void encode(byte aBuffer[], OutputStream aStream) 144 | throws IOException { 145 | ByteArrayInputStream inStream = new ByteArrayInputStream(aBuffer); 146 | encode(inStream, aStream); 147 | } 148 | 149 | /** 150 | * A 'streamless' version of encode that simply takes a buffer of 151 | * bytes and returns a string containing the encoded buffer. 152 | */ 153 | public String encode(byte aBuffer[]) { 154 | ByteArrayOutputStream outStream = new ByteArrayOutputStream(); 155 | ByteArrayInputStream inStream = new ByteArrayInputStream(aBuffer); 156 | String retVal = null; 157 | try { 158 | encode(inStream, outStream); 159 | // explicit ascii->unicode conversion 160 | retVal = outStream.toString("8859_1"); 161 | } catch (Exception IOException) { 162 | // This should never happen. 163 | throw new Error("CharacterEncoder.encode internal error"); 164 | } 165 | return (retVal); 166 | } 167 | 168 | /** 169 | * This method works around the bizarre semantics of BufferedInputStream's 170 | * read method. 171 | */ 172 | protected int readFully(InputStream in, byte buffer[]) 173 | throws java.io.IOException { 174 | for (int i = 0; i < buffer.length; i++) { 175 | int q = in.read(); 176 | if (q == -1) 177 | return i; 178 | buffer[i] = (byte)q; 179 | } 180 | return buffer.length; 181 | } 182 | 183 | /** 184 | * Encode the suffix for the entire buffer. 185 | */ 186 | protected void encodeBufferSuffix(OutputStream aStream) throws IOException { 187 | } 188 | 189 | 190 | /** 191 | * Return a byte array from the remaining bytes in this ByteBuffer. 192 | *

193 | * The ByteBuffer's position will be advanced to ByteBuffer's limit. 194 | *

195 | * To avoid an extra copy, the implementation will attempt to return the 196 | * byte array backing the ByteBuffer. If this is not possible, a 197 | * new byte array will be created. 198 | */ 199 | private byte [] getBytes(ByteBuffer bb) { 200 | /* 201 | * This should never return a BufferOverflowException, as we're 202 | * careful to allocate just the right amount. 203 | */ 204 | byte [] buf = null; 205 | 206 | /* 207 | * If it has a usable backing byte buffer, use it. Use only 208 | * if the array exactly represents the current ByteBuffer. 209 | */ 210 | if (bb.hasArray()) { 211 | byte [] tmp = bb.array(); 212 | if ((tmp.length == bb.capacity()) && 213 | (tmp.length == bb.remaining())) { 214 | buf = tmp; 215 | bb.position(bb.limit()); 216 | } 217 | } 218 | 219 | if (buf == null) { 220 | /* 221 | * This class doesn't have a concept of encode(buf, len, off), 222 | * so if we have a partial buffer, we must reallocate 223 | * space. 224 | */ 225 | buf = new byte[bb.remaining()]; 226 | 227 | /* 228 | * position() automatically updated 229 | */ 230 | bb.get(buf); 231 | } 232 | 233 | return buf; 234 | } 235 | 236 | /** 237 | * Encode the aBuffer ByteBuffer and write the encoded 238 | * result to the OutputStream aStream. 239 | *

240 | * The ByteBuffer's position will be advanced to ByteBuffer's limit. 241 | */ 242 | public void encode(ByteBuffer aBuffer, OutputStream aStream) 243 | throws IOException { 244 | byte [] buf = getBytes(aBuffer); 245 | encode(buf, aStream); 246 | } 247 | 248 | /** 249 | * A 'streamless' version of encode that simply takes a ByteBuffer 250 | * and returns a string containing the encoded buffer. 251 | *

252 | * The ByteBuffer's position will be advanced to ByteBuffer's limit. 253 | */ 254 | public String encode(ByteBuffer aBuffer) { 255 | byte [] buf = getBytes(aBuffer); 256 | return encode(buf); 257 | } 258 | 259 | /** 260 | * Encode bytes from the input stream, and write them as text characters 261 | * to the output stream. This method will run until it exhausts the 262 | * input stream. It differs from encode in that it will add the 263 | * line at the end of a final line that is shorter than bytesPerLine(). 264 | */ 265 | public void encodeBuffer(InputStream inStream, OutputStream outStream) 266 | throws IOException { 267 | int j; 268 | int numBytes; 269 | byte tmpbuffer[] = new byte[bytesPerLine()]; 270 | 271 | encodeBufferPrefix(outStream); 272 | 273 | while (true) { 274 | numBytes = readFully(inStream, tmpbuffer); 275 | if (numBytes == 0) { 276 | break; 277 | } 278 | encodeLinePrefix(outStream, numBytes); 279 | for (j = 0; j < numBytes; j += bytesPerAtom()) { 280 | if ((j + bytesPerAtom()) <= numBytes) { 281 | encodeAtom(outStream, tmpbuffer, j, bytesPerAtom()); 282 | } else { 283 | encodeAtom(outStream, tmpbuffer, j, (numBytes)- j); 284 | } 285 | } 286 | encodeLineSuffix(outStream); 287 | if (numBytes < bytesPerLine()) { 288 | break; 289 | } 290 | } 291 | encodeBufferSuffix(outStream); 292 | } 293 | 294 | /** 295 | * Encode the buffer in aBuffer and write the encoded 296 | * result to the OutputStream aStream. 297 | */ 298 | public void encodeBuffer(byte aBuffer[], OutputStream aStream) 299 | throws IOException { 300 | ByteArrayInputStream inStream = new ByteArrayInputStream(aBuffer); 301 | encodeBuffer(inStream, aStream); 302 | } 303 | 304 | /** 305 | * A 'streamless' version of encode that simply takes a buffer of 306 | * bytes and returns a string containing the encoded buffer. 307 | */ 308 | public String encodeBuffer(byte aBuffer[]) { 309 | ByteArrayOutputStream outStream = new ByteArrayOutputStream(); 310 | ByteArrayInputStream inStream = new ByteArrayInputStream(aBuffer); 311 | try { 312 | encodeBuffer(inStream, outStream); 313 | } catch (Exception IOException) { 314 | // This should never happen. 315 | throw new Error("CharacterEncoder.encodeBuffer internal error"); 316 | } 317 | return (outStream.toString()); 318 | } 319 | 320 | /** 321 | * Encode the aBuffer ByteBuffer and write the encoded 322 | * result to the OutputStream aStream. 323 | *

324 | * The ByteBuffer's position will be advanced to ByteBuffer's limit. 325 | */ 326 | public void encodeBuffer(ByteBuffer aBuffer, OutputStream aStream) 327 | throws IOException { 328 | byte [] buf = getBytes(aBuffer); 329 | encodeBuffer(buf, aStream); 330 | } 331 | 332 | /** 333 | * A 'streamless' version of encode that simply takes a ByteBuffer 334 | * and returns a string containing the encoded buffer. 335 | *

336 | * The ByteBuffer's position will be advanced to ByteBuffer's limit. 337 | */ 338 | public String encodeBuffer(ByteBuffer aBuffer) { 339 | byte [] buf = getBytes(aBuffer); 340 | return encodeBuffer(buf); 341 | } 342 | 343 | } 344 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/api/Play.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.api 2 | 3 | import com.akdeniz.googleplaycrawler.GooglePlayAPI 4 | import com.github.theapache64.gpa.core.SearchEngineResultPage 5 | import com.github.theapache64.gpa.model.Account 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.withContext 10 | 11 | object Play { 12 | private const val USER_AGENT = 13 | "Android-Finsky/13.1.32-all (versionCode=81313200,sdk=24,device=dream2lte,hardware=dream2lte,product=dream2ltexx,build=NRD90M:user)" 14 | 15 | /** 16 | * To login and get userToken and gsfId 17 | */ 18 | suspend fun login( 19 | username: String, 20 | password: String, 21 | locale: String = PlayUtils.getLocalization(), 22 | dispatcher: CoroutineDispatcher = Dispatchers.IO, 23 | loginDelay: Long = 10_000, 24 | sdkVersion: Int = 17 25 | ): Account = withContext(dispatcher) { 26 | 27 | // Building GooglePlayAPI 28 | val api = GooglePlayAPI( 29 | username, 30 | password 31 | ).apply { 32 | client = PlayUtils.createLoginClient() 33 | localization = locale 34 | useragent = USER_AGENT 35 | } 36 | 37 | 38 | // Requesting for login 39 | api.login() 40 | 41 | // To get GSF id 42 | api.checkin() 43 | 44 | // Upload device config 45 | api.uploadDeviceConfig() 46 | 47 | // giving time to sync the device config in google servers. 48 | delay(loginDelay) 49 | 50 | Account( 51 | username, 52 | password, 53 | api.token, 54 | api.androidID, 55 | locale 56 | ) 57 | } 58 | 59 | fun getApi(account: Account): GooglePlayAPI { 60 | return GooglePlayAPI( 61 | account.username, 62 | account.password 63 | ).apply { 64 | useragent = USER_AGENT 65 | androidID = account.gsfId 66 | token = account.token 67 | localization = account.locale 68 | } 69 | } 70 | 71 | suspend fun search( 72 | query: String, 73 | api: GooglePlayAPI, 74 | _serp: SearchEngineResultPage? = null, 75 | ): SearchEngineResultPage = withContext(Dispatchers.IO) { 76 | 77 | var serp = _serp 78 | var nextPageUrl: String? = null 79 | 80 | if (serp != null) { 81 | // second+ time 82 | nextPageUrl = serp.nextPageUrl 83 | } 84 | 85 | if (serp == null) { 86 | serp = SearchEngineResultPage(SearchEngineResultPage.SEARCH) 87 | } 88 | 89 | serp.append(api.searchApp(query)) 90 | 91 | if (nextPageUrl == null) { 92 | // first time 93 | nextPageUrl = serp.nextPageUrl 94 | } 95 | 96 | if (nextPageUrl?.isNotBlank() == true) { 97 | serp.append(api.getList(nextPageUrl)) 98 | } 99 | 100 | serp 101 | } 102 | 103 | 104 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/api/PlayUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.api 2 | 3 | import com.github.theapache64.gpa.core.net.DroidConnectionSocketFactory 4 | import org.apache.http.client.HttpClient 5 | import org.apache.http.client.config.RequestConfig 6 | import org.apache.http.config.RegistryBuilder 7 | import org.apache.http.conn.socket.ConnectionSocketFactory 8 | import org.apache.http.impl.client.HttpClientBuilder 9 | import org.apache.http.impl.conn.PoolingHttpClientConnectionManager 10 | import java.util.* 11 | 12 | internal object PlayUtils { 13 | fun createLoginClient(): HttpClient? { 14 | val rb = RegistryBuilder.create() 15 | rb.register("https", DroidConnectionSocketFactory()) 16 | // rb.register("http", new DroidConnectionSocketFactory()); 17 | val connManager = PoolingHttpClientConnectionManager( 18 | rb.build() 19 | ) 20 | connManager.maxTotal = 100 21 | connManager.defaultMaxPerRoute = 30 22 | // TODO: Increase the max connection limits. If we are doing bulkdownloads, 23 | // we will download from multiple hosts. 24 | val timeout = 9 25 | val config = RequestConfig.custom() 26 | .setConnectTimeout(timeout * 1000) 27 | .setConnectionRequestTimeout(timeout * 1000) 28 | .setSocketTimeout(timeout * 1000).build() 29 | val hcb = HttpClientBuilder.create().setDefaultRequestConfig( 30 | config 31 | ) 32 | return hcb.setConnectionManager(connManager).build() 33 | } 34 | 35 | fun getLocalization(): String { 36 | return Locale.getDefault().let { locale -> 37 | "${locale.language}-${locale.country}" 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/core/SearchEngineResultPage.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.core 2 | 3 | import com.akdeniz.googleplaycrawler.GooglePlay 4 | import com.akdeniz.googleplaycrawler.GooglePlay.DocV2 5 | import com.akdeniz.googleplaycrawler.GooglePlay.PreFetch 6 | import com.google.protobuf.InvalidProtocolBufferException 7 | import java.util.* 8 | 9 | /** 10 | * A (relatively) smart adapter for transforming the various search response 11 | * formats into a flat, continuous list. 12 | * 13 | * @author patrick 14 | */ 15 | class SearchEngineResultPage(type: Int) { 16 | private val items: ArrayList = ArrayList() 17 | 18 | /** 19 | * Check if results are available. 20 | * 21 | * @return null if there are no more search results to load. 22 | */ 23 | var nextPageUrl: String? 24 | private set 25 | 26 | /** 27 | * Get the title of this page (if any). 28 | * 29 | * @return null or the title of the first appended doc. 30 | */ 31 | var title: String? = null 32 | private set 33 | private val type: Int 34 | 35 | /** 36 | * Try to make sense of a [ResponseWrapper], containing a search result. 37 | * 38 | * @param rw 39 | * a wrapper containing either a [SearchResponse], 40 | * [ListResponse] or a [PreFetch] 41 | */ 42 | fun append(rw: GooglePlay.ResponseWrapper) { 43 | // The SearchResponse format changed considerably over time. The message 44 | // type seems to have gotten deprecated for Android 5 and later in favor of 45 | // ListResponse. Apparently, SearchResponse got too too unwieldy. 46 | append(Unwrap.searchResponse(rw).docList) 47 | append(Unwrap.listResponse(rw).docList) 48 | for (pf in rw.preFetchList) { 49 | try { 50 | append(GooglePlay.ResponseWrapper.parseFrom(pf.response)) 51 | } catch (e: InvalidProtocolBufferException) { 52 | // We tried, we failed. 53 | } 54 | } 55 | } 56 | 57 | private fun append(list: List) { 58 | for (doc in list) { 59 | append(doc) 60 | } 61 | } 62 | 63 | /** 64 | * Grow the SERP 65 | * 66 | * @param doc 67 | * a document of type [DocumentType.PRODUCTLIST] or a document 68 | * containing a [DocumentType.PRODUCTLIST]. 69 | */ 70 | fun append(doc: DocV2) { 71 | when (doc.docType) { 72 | 46 -> { 73 | for (child in doc.childList) { 74 | if (accept(child)) { 75 | append(child) 76 | } 77 | } 78 | } 79 | 45 -> { 80 | for (d in doc.childList) { 81 | if (d.docType == 1) { 82 | items.add(d) 83 | } 84 | } 85 | nextPageUrl = null 86 | if (doc.hasContainerMetadata()) { 87 | nextPageUrl = doc.containerMetadata.nextPageUrl 88 | } 89 | if (title == null && doc.hasTitle()) { 90 | title = doc.title 91 | } 92 | } 93 | else -> { 94 | for (child in doc.childList) { 95 | append(child) 96 | } 97 | } 98 | } 99 | } 100 | 101 | private fun accept(doc: DocV2): Boolean { 102 | val dbid = doc.backendDocid 103 | return when (type) { 104 | ALL -> { 105 | true 106 | } 107 | SEARCH -> { 108 | dbid != null && dbid.matches(".*search.*".toRegex()) 109 | } 110 | SIMILIAR -> { 111 | dbid != null && dbid.matches("similar_apps".toRegex()) 112 | } 113 | RELATED -> { 114 | dbid != null && dbid 115 | .matches("pre_install_users_also_installed".toRegex()) 116 | } 117 | else -> { 118 | false 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * Get the entry list. 125 | * 126 | * @return a flat list. 127 | */ 128 | val content: List 129 | get() = items 130 | 131 | override fun toString(): String { 132 | val ret = StringBuilder() 133 | if (title != null) { 134 | ret.append('[') 135 | ret.append(title) 136 | ret.append("]\n") 137 | } 138 | for (item in items) { 139 | ret.append(item.docid) 140 | ret.append(", ") 141 | ret.append("\"") 142 | ret.append(item.title) 143 | ret.append("\"\n") 144 | } 145 | if (nextPageUrl != null) { 146 | ret.append("-> ") 147 | ret.append(nextPageUrl) 148 | ret.append('\n') 149 | } 150 | return ret.toString() 151 | } 152 | 153 | companion object { 154 | /** 155 | * Type: everything 156 | */ 157 | const val ALL = 0 158 | 159 | /** 160 | * Type: only append what was searched for 161 | */ 162 | const val SEARCH = 1 163 | 164 | /** 165 | * Type: only append similar items. This requires an exact match 166 | */ 167 | const val SIMILIAR = 2 168 | 169 | /** 170 | * Type: only append items of the "other users also..." type. This requires an 171 | * exact match. 172 | */ 173 | const val RELATED = 3 174 | } 175 | 176 | /** 177 | * 178 | * @param type 179 | * Either ALL, SEARCH, SIMILAR or RELATED. Only Applies when trying 180 | * to add [DocumentType.MULTILIST]. 181 | */ 182 | init { 183 | nextPageUrl = null 184 | this.type = type 185 | } 186 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/core/Unwrap.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.core 2 | 3 | import com.akdeniz.googleplaycrawler.GooglePlay 4 | import com.akdeniz.googleplaycrawler.GooglePlay.Payload 5 | import com.akdeniz.googleplaycrawler.GooglePlay.SearchResponse 6 | import com.github.theapache64.gpa.core.Unwrap 7 | import com.akdeniz.googleplaycrawler.GooglePlay.ListResponse 8 | import com.akdeniz.googleplaycrawler.GooglePlay.DeliveryResponse 9 | import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsResponse 10 | import com.akdeniz.googleplaycrawler.GooglePlay.DetailsResponse 11 | import com.akdeniz.googleplaycrawler.GooglePlay.TocResponse 12 | import com.akdeniz.googleplaycrawler.GooglePlay.UploadDeviceConfigResponse 13 | 14 | /** 15 | * Extract a response from a [ResponseWrapper]. Return an empty instance 16 | * if the requested message is not available. 17 | * 18 | * @author patrick 19 | */ 20 | object Unwrap { 21 | fun payload(rw: GooglePlay.ResponseWrapper?): Payload { 22 | return if (rw != null && rw.hasPayload()) { 23 | rw.payload 24 | } else Payload.getDefaultInstance() 25 | } 26 | 27 | fun searchResponse(rw: GooglePlay.ResponseWrapper?): SearchResponse { 28 | val pl = payload(rw) 29 | return if (payload(rw).hasSearchResponse()) { 30 | pl.searchResponse 31 | } else SearchResponse.getDefaultInstance() 32 | } 33 | 34 | fun listResponse(rw: GooglePlay.ResponseWrapper?): ListResponse { 35 | val pl = payload(rw) 36 | return if (pl.hasListResponse()) { 37 | pl.listResponse 38 | } else ListResponse.getDefaultInstance() 39 | } 40 | 41 | fun deliveryResponse(rw: GooglePlay.ResponseWrapper?): DeliveryResponse { 42 | val pl = payload(rw) 43 | return if (pl.hasDeliveryResponse()) { 44 | pl.deliveryResponse 45 | } else DeliveryResponse.getDefaultInstance() 46 | } 47 | 48 | fun bulkDetailsResponse(rw: GooglePlay.ResponseWrapper?): BulkDetailsResponse { 49 | val pl = payload(rw) 50 | return if (pl.hasBulkDetailsResponse()) { 51 | pl.bulkDetailsResponse 52 | } else BulkDetailsResponse.getDefaultInstance() 53 | } 54 | 55 | fun detailsResponse(rw: GooglePlay.ResponseWrapper?): DetailsResponse { 56 | val pl = payload(rw) 57 | return if (pl.hasDetailsResponse()) { 58 | pl.detailsResponse 59 | } else DetailsResponse.getDefaultInstance() 60 | } 61 | 62 | fun tocResponse(rw: GooglePlay.ResponseWrapper?): TocResponse { 63 | val pl = payload(rw) 64 | return if (pl.hasTocResponse()) { 65 | pl.tocResponse 66 | } else TocResponse.getDefaultInstance() 67 | } 68 | 69 | fun uploadDeviceConfigResponse( 70 | rw: GooglePlay.ResponseWrapper? 71 | ): UploadDeviceConfigResponse { 72 | val pl = payload(rw) 73 | return if (pl.hasUploadDeviceConfigResponse()) { 74 | pl.uploadDeviceConfigResponse 75 | } else UploadDeviceConfigResponse.getDefaultInstance() 76 | } 77 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/core/net/DefaultTlsAuthentication.kt: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Copyright 2020 Patrick Ahlbrecht 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy 6 | * of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | package com.github.theapache64.gpa.core.net 17 | 18 | import org.bouncycastle.tls.* 19 | import java.io.ByteArrayInputStream 20 | import java.io.IOException 21 | import java.security.KeyStore 22 | import java.security.cert.* 23 | import javax.net.ssl.TrustManager 24 | import javax.net.ssl.TrustManagerFactory 25 | import javax.net.ssl.X509TrustManager 26 | 27 | class DefaultTlsAuthentication(selectedCipherSuite: Int) : ServerOnlyTlsAuthentication() { 28 | private var trustManagers: Array? = null 29 | private var certificateFactory: CertificateFactory? = null 30 | private var authType: String? = null 31 | 32 | @Throws(IOException::class) 33 | override fun notifyServerCertificate(serverCertificate: org.bouncycastle.tls.Certificate?) { 34 | if (serverCertificate == null || serverCertificate.isEmpty) { 35 | throw TlsFatalAlert(AlertDescription.handshake_failure) 36 | } 37 | if (trustManagers == null || certificateFactory == null) { 38 | throw TlsFatalAlert(AlertDescription.unknown_ca) 39 | } 40 | if (authType == null) { 41 | throw TlsFatalAlert(AlertDescription.internal_error) 42 | } 43 | val certificates = serverCertificate.certificateList 44 | val chain = arrayOfNulls(certificates.size) 45 | var bis: ByteArrayInputStream? = null 46 | for (i in chain.indices) { 47 | bis = ByteArrayInputStream(certificates[i].encoded) 48 | try { 49 | chain[i] = certificateFactory!!.generateCertificate(bis) as X509Certificate 50 | chain[i]!!.checkValidity() 51 | } catch (e: CertificateExpiredException) { 52 | throw TlsFatalAlert(AlertDescription.certificate_expired) 53 | } catch (e: CertificateNotYetValidException) { 54 | throw TlsFatalAlert(AlertDescription.certificate_expired) 55 | } catch (e: CertificateException) { 56 | throw TlsFatalAlert(AlertDescription.decode_error, e) 57 | } 58 | } 59 | for (trustManager in trustManagers!!) { 60 | if (trustManager is X509TrustManager) { 61 | try { 62 | trustManager.checkServerTrusted(chain, authType) 63 | } catch (e: Exception) { 64 | throw IOException(e.cause) 65 | } 66 | } 67 | } 68 | } 69 | 70 | private fun getAuthTypeServer(keyExchangeAlgorithm: Int): String? { 71 | return when (keyExchangeAlgorithm) { 72 | KeyExchangeAlgorithm.DH_anon -> "DH_anon" 73 | KeyExchangeAlgorithm.DH_DSS -> "DH_DSS" 74 | KeyExchangeAlgorithm.DH_RSA -> "DH_RSA" 75 | KeyExchangeAlgorithm.DHE_DSS -> "DHE_DSS" 76 | KeyExchangeAlgorithm.DHE_PSK -> "DHE_PSK" 77 | KeyExchangeAlgorithm.DHE_RSA -> "DHE_RSA" 78 | KeyExchangeAlgorithm.ECDH_anon -> "ECDH_anon" 79 | KeyExchangeAlgorithm.ECDH_ECDSA -> "ECDH_ECDSA" 80 | KeyExchangeAlgorithm.ECDH_RSA -> "ECDH_RSA" 81 | KeyExchangeAlgorithm.ECDHE_ECDSA -> "ECDHE_ECDSA" 82 | KeyExchangeAlgorithm.ECDHE_PSK -> "ECDHE_PSK" 83 | KeyExchangeAlgorithm.ECDHE_RSA -> "ECDHE_RSA" 84 | KeyExchangeAlgorithm.RSA -> "RSA" 85 | KeyExchangeAlgorithm.RSA_PSK -> "RSA_PSK" 86 | KeyExchangeAlgorithm.SRP -> "SRP" 87 | KeyExchangeAlgorithm.SRP_DSS -> "SRP_DSS" 88 | KeyExchangeAlgorithm.SRP_RSA -> "SRP_RSA" 89 | else -> null 90 | } 91 | } 92 | 93 | init { 94 | try { 95 | val trustManagerFactory = TrustManagerFactory 96 | .getInstance(TrustManagerFactory.getDefaultAlgorithm()) 97 | trustManagerFactory.init(null as KeyStore?) 98 | trustManagers = trustManagerFactory.trustManagers 99 | certificateFactory = CertificateFactory.getInstance("X.509") 100 | val keyExchangeAlgorithm = TlsUtils 101 | .getKeyExchangeAlgorithm(selectedCipherSuite) 102 | authType = getAuthTypeServer(keyExchangeAlgorithm) 103 | } catch (e: Exception) { 104 | e.printStackTrace() 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/core/net/DroidConnectionSocketFactory.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.core.net 2 | 3 | import org.apache.http.HttpHost 4 | import org.apache.http.conn.socket.LayeredConnectionSocketFactory 5 | import org.apache.http.protocol.HttpContext 6 | import java.io.IOException 7 | import java.net.InetSocketAddress 8 | import java.net.Socket 9 | import java.net.UnknownHostException 10 | 11 | class DroidConnectionSocketFactory : LayeredConnectionSocketFactory { 12 | 13 | @Throws(IOException::class) 14 | override fun createSocket(context: HttpContext): Socket { 15 | return Socket() 16 | } 17 | 18 | @Throws(IOException::class) 19 | override fun connectSocket( 20 | connectTimeout: Int, 21 | socket: Socket?, 22 | host: HttpHost, 23 | remoteAddress: InetSocketAddress, 24 | localAddress: InetSocketAddress?, 25 | context: HttpContext 26 | ): Socket { 27 | val sock = socket ?: createSocket(context) 28 | sock.connect(remoteAddress, connectTimeout) 29 | return createLayeredSocket( 30 | sock, host.hostName, 31 | remoteAddress.port, context 32 | ) 33 | } 34 | 35 | @Throws(IOException::class, UnknownHostException::class) 36 | override fun createLayeredSocket( 37 | socket: Socket, target: String, port: Int, 38 | context: HttpContext 39 | ): Socket { 40 | return DroidSocket(socket) 41 | } 42 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/core/net/DroidSocket.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.core.net 2 | 3 | import org.bouncycastle.tls.TlsClient 4 | import org.bouncycastle.tls.TlsClientProtocol 5 | import java.net.Socket 6 | import javax.net.ssl.SSLSocket 7 | import kotlin.Throws 8 | import java.io.IOException 9 | import org.bouncycastle.tls.crypto.TlsCrypto 10 | import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto 11 | import java.io.InputStream 12 | import java.io.OutputStream 13 | import java.lang.UnsupportedOperationException 14 | import java.security.SecureRandom 15 | import javax.net.ssl.SSLSession 16 | import javax.net.ssl.HandshakeCompletedListener 17 | 18 | /** 19 | * An encrypted layer on top of a plain socket. 20 | * 21 | * @original_author patrick 22 | * @kotlin_author theapache64 23 | */ 24 | internal class DroidSocket(private val base: Socket) : SSLSocket() { 25 | 26 | private var client: TlsClient? = null 27 | private var protocol: TlsClientProtocol? = null 28 | @Throws(IOException::class) 29 | override fun startHandshake() { 30 | if (protocol == null) { 31 | protocol = TlsClientProtocol( 32 | base.getInputStream(), 33 | base.getOutputStream() 34 | ) 35 | val crypto: TlsCrypto = BcTlsCrypto(SecureRandom()) 36 | client = JellyBeanTlsClient(crypto) 37 | protocol!!.connect(client) 38 | } 39 | } 40 | 41 | @Throws(IOException::class) 42 | override fun close() { 43 | base.close() 44 | } 45 | 46 | override fun isClosed(): Boolean { 47 | return base.isClosed 48 | } 49 | 50 | @Throws(IOException::class) 51 | override fun getInputStream(): InputStream { 52 | if (protocol == null) { 53 | startHandshake() 54 | } 55 | return protocol!!.inputStream 56 | } 57 | 58 | @Throws(IOException::class) 59 | override fun getOutputStream(): OutputStream { 60 | if (protocol == null) { 61 | startHandshake() 62 | } 63 | return protocol!!.outputStream 64 | } 65 | 66 | override fun getSupportedCipherSuites(): Array { 67 | return arrayOf( 68 | "SSL_RSA_WITH_RC4_128_MD5", 69 | "SSL_RSA_WITH_RC4_128_SHA", "TLS_RSA_WITH_AES_128_CBC_SHA", 70 | "TLS_RSA_WITH_AES_256_CBC_SHA", 71 | "TLS_ECDH_ECDSA_WITH_RC4_128_SHA", 72 | "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA", 73 | "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA", "TLS_ECDH_RSA_WITH_RC4_128_SHA", 74 | "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA", 75 | "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA", 76 | "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", 77 | "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", 78 | "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", 79 | "TLS_ECDHE_RSA_WITH_RC4_128_SHA", "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", 80 | "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", 81 | "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", 82 | "TLS_DHE_DSS_WITH_AES_128_CBC_SHA", "TLS_DHE_DSS_WITH_AES_256_CBC_SHA", 83 | "SSL_RSA_WITH_3DES_EDE_CBC_SHA", 84 | "TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA", 85 | "TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA", 86 | "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", 87 | "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", 88 | "SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA", 89 | "SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA", "SSL_RSA_WITH_DES_CBC_SHA", 90 | "SSL_DHE_RSA_WITH_DES_CBC_SHA", "SSL_DHE_DSS_WITH_DES_CBC_SHA", 91 | "SSL_RSA_EXPORT_WITH_RC4_40_MD5", "SSL_RSA_EXPORT_WITH_DES40_CBC_SHA", 92 | "SSL_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", 93 | "SSL_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA", 94 | "TLS_EMPTY_RENEGOTIATION_INFO_SCSV" 95 | ) 96 | } 97 | 98 | override fun getEnabledCipherSuites(): Array { 99 | return supportedCipherSuites 100 | } 101 | 102 | override fun setEnabledCipherSuites(suites: Array) { 103 | throw UnsupportedOperationException( 104 | "This would change the SSL fingerprint" 105 | ) 106 | } 107 | 108 | override fun getSupportedProtocols(): Array { 109 | return arrayOf("SSLv3") 110 | } 111 | 112 | override fun getEnabledProtocols(): Array { 113 | return supportedProtocols 114 | } 115 | 116 | override fun setEnabledProtocols(protocols: Array) { 117 | throw UnsupportedOperationException( 118 | "This would change the SSL fingerprint" 119 | ) 120 | } 121 | 122 | override fun getSession(): SSLSession? { 123 | return null 124 | } 125 | 126 | override fun addHandshakeCompletedListener(listener: HandshakeCompletedListener) { 127 | throw UnsupportedOperationException("Do we need this?") 128 | } 129 | 130 | override fun removeHandshakeCompletedListener( 131 | listener: HandshakeCompletedListener 132 | ) { 133 | throw UnsupportedOperationException("Do we need this?") 134 | } 135 | 136 | override fun setUseClientMode(mode: Boolean) { 137 | if (!mode) { 138 | throw UnsupportedOperationException("This socket is client mode only") 139 | } 140 | } 141 | 142 | override fun getUseClientMode(): Boolean { 143 | return true 144 | } 145 | 146 | override fun setNeedClientAuth(need: Boolean) { 147 | if (need) { 148 | throw UnsupportedOperationException("Nope!") 149 | } 150 | } 151 | 152 | override fun getNeedClientAuth(): Boolean { 153 | return false 154 | } 155 | 156 | override fun setWantClientAuth(want: Boolean) { 157 | if (want) { 158 | throw UnsupportedOperationException("Don't care") 159 | } 160 | } 161 | 162 | override fun getWantClientAuth(): Boolean { 163 | return false 164 | } 165 | 166 | override fun setEnableSessionCreation(flag: Boolean) {} 167 | override fun getEnableSessionCreation(): Boolean { 168 | return false 169 | } 170 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/core/net/JellyBeanTlsClient.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.core.net 2 | 3 | import org.bouncycastle.tls.* 4 | import org.bouncycastle.tls.crypto.TlsCrypto 5 | import org.bouncycastle.util.Arrays 6 | import java.io.IOException 7 | import java.util.* 8 | 9 | /** 10 | * Spoofs the SSL Handshake of a Jelly Bean device. 11 | * 12 | * @author patrick 13 | */ 14 | internal class JellyBeanTlsClient(crypto: TlsCrypto?) : DefaultTlsClient(crypto) { 15 | @Throws(IOException::class) 16 | override fun getAuthentication(): TlsAuthentication { 17 | return DefaultTlsAuthentication(selectedCipherSuite) 18 | } 19 | 20 | override fun getClientVersion(): ProtocolVersion { 21 | return ProtocolVersion.TLSv10 22 | } 23 | 24 | @Throws(IOException::class) 25 | override fun getClientExtensions(): Hashtable<*, *> { 26 | val ret = OrderedHashtable() 27 | val clientVersion = context.clientVersion 28 | 29 | /* 30 | * RFC 5246 7.4.1.4.1. Note: this extension is not meaningful for TLS 31 | * versions prior to 1.2. Clients MUST NOT offer it if they are offering 32 | * prior versions. 33 | */if (TlsUtils.isSignatureAlgorithmsExtensionAllowed(clientVersion)) { 34 | supportedSignatureAlgorithms = getSupportedSignatureAlgorithms() 35 | TlsUtils.addSignatureAlgorithmsExtension( 36 | ret, 37 | supportedSignatureAlgorithms 38 | ) 39 | } 40 | if (TlsECCUtils.containsECCipherSuites(cipherSuites)) { 41 | /* 42 | * RFC 4492 5.1. A client that proposes ECC cipher suites in its 43 | * ClientHello message appends these extensions (along with any others), 44 | * enumerating the curves it supports and the point formats it can parse. 45 | * Clients SHOULD send both the Supported Elliptic Curves Extension and 46 | * the Supported Point Formats Extension. 47 | */ 48 | namedCurves = intArrayOf( 49 | NamedCurve.sect571r1, 50 | NamedCurve.sect571k1, NamedCurve.secp521r1, NamedCurve.sect409k1, 51 | NamedCurve.sect409r1, NamedCurve.secp384r1, NamedCurve.sect283k1, 52 | NamedCurve.sect283r1, NamedCurve.secp256k1, NamedCurve.secp256r1, 53 | NamedCurve.sect239k1, NamedCurve.sect233k1, NamedCurve.sect233r1, 54 | NamedCurve.secp224k1, NamedCurve.secp224r1, NamedCurve.sect193r1, 55 | NamedCurve.sect193r2, NamedCurve.secp192k1, NamedCurve.secp192r1, 56 | NamedCurve.sect163k1, NamedCurve.sect163r1, NamedCurve.sect163r2, 57 | NamedCurve.secp160k1, NamedCurve.secp160r1, NamedCurve.secp160r2 58 | ) 59 | clientECPointFormats = shortArrayOf( 60 | ECPointFormat.uncompressed, 61 | ECPointFormat.ansiX962_compressed_prime, 62 | ECPointFormat.ansiX962_compressed_char2 63 | ) 64 | TlsECCUtils.addSupportedPointFormatsExtension(ret, clientECPointFormats) 65 | TlsECCUtils.addSupportedEllipticCurvesExtension(ret, namedCurves) 66 | } 67 | return ret 68 | } 69 | 70 | override fun getCipherSuites(): IntArray { 71 | return Arrays.clone(SUITES) 72 | } 73 | 74 | override fun notifySessionID(sessionID: ByteArray) { 75 | super.notifySessionID(sessionID) 76 | } 77 | 78 | override fun getSessionToResume(): TlsSession? { 79 | return null 80 | } 81 | 82 | companion object { 83 | private val SUITES = intArrayOf( 84 | CipherSuite.TLS_RSA_WITH_RC4_128_MD5, 85 | CipherSuite.TLS_RSA_WITH_RC4_128_SHA, 86 | CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA, 87 | CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA, 88 | CipherSuite.TLS_ECDH_ECDSA_WITH_RC4_128_SHA, 89 | CipherSuite.TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA, 90 | CipherSuite.TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA, 91 | CipherSuite.TLS_ECDH_RSA_WITH_RC4_128_SHA, 92 | CipherSuite.TLS_ECDH_RSA_WITH_AES_128_CBC_SHA, 93 | CipherSuite.TLS_ECDH_RSA_WITH_AES_256_CBC_SHA, 94 | CipherSuite.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, 95 | CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, 96 | CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, 97 | CipherSuite.TLS_ECDHE_RSA_WITH_RC4_128_SHA, 98 | CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, 99 | CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, 100 | CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA, 101 | CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA, 102 | CipherSuite.TLS_DHE_DSS_WITH_AES_128_CBC_SHA, 103 | CipherSuite.TLS_DHE_DSS_WITH_AES_256_CBC_SHA, 104 | CipherSuite.TLS_RSA_WITH_3DES_EDE_CBC_SHA, 105 | CipherSuite.TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA, 106 | CipherSuite.TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA, 107 | CipherSuite.TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA, 108 | CipherSuite.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, 109 | CipherSuite.TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA, 110 | CipherSuite.TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA, 111 | CipherSuite.TLS_RSA_WITH_DES_CBC_SHA, 112 | CipherSuite.TLS_DHE_RSA_WITH_DES_CBC_SHA, 113 | CipherSuite.TLS_DHE_DSS_WITH_DES_CBC_SHA, 114 | CipherSuite.TLS_RSA_EXPORT_WITH_RC4_40_MD5, 115 | CipherSuite.TLS_RSA_EXPORT_WITH_DES40_CBC_SHA, 116 | CipherSuite.TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA, 117 | CipherSuite.TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA, 118 | CipherSuite.TLS_EMPTY_RENEGOTIATION_INFO_SCSV 119 | ) 120 | } 121 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/core/net/OrderedHashtable.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.core.net 2 | 3 | import java.util.* 4 | 5 | /** 6 | * Dirty hack to maintain key order. 7 | * 8 | * @author patrick 9 | */ 10 | internal class OrderedHashtable : Hashtable() { 11 | 12 | private val ordered = Vector() 13 | 14 | @Synchronized 15 | override fun put(key: Int, value: Any): Any { 16 | super.put(key, value) 17 | ordered.add(key) 18 | return value 19 | } 20 | 21 | @Synchronized 22 | override fun keys(): Enumeration { 23 | return ordered.elements() 24 | } 25 | 26 | companion object { 27 | /** 28 | * 29 | */ 30 | private const val serialVersionUID = 1L 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/theapache64/gpa/model/Account.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.model 2 | 3 | data class Account( 4 | val username: String, 5 | val password: String, 6 | val token: String, 7 | val gsfId: String, 8 | val locale: String, 9 | ) -------------------------------------------------------------------------------- /src/main/resources/com/akdeniz/googleplaycrawler/crypt.properties: -------------------------------------------------------------------------------- 1 | key=AAAAgMom/1a/v0lblO2Ubrt60J2gcuXSljGFQXgcyZWveWLEwo6prwgi3iJIZdodyhKZQrNWp5nKJ3srRXcUW+F1BD3baEVGcmEgqaLZUNBjm057pKRI16kB0YppeGx5qIQ5QjKzsR8ETQbKLNWgRY0QRNVz34kMJR3P/LgHax/6rmf5AAAAAwEAAQ== 2 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/theapache64/gpa/api/PlayTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.api 2 | 3 | import com.akdeniz.googleplaycrawler.GooglePlayAPI 4 | import com.akdeniz.googleplaycrawler.GooglePlayException 5 | import com.github.theapache64.gpa.utils.runBlockingTest 6 | import com.theapache64.expekt.should 7 | import org.apache.http.client.ClientProtocolException 8 | import org.junit.jupiter.api.BeforeAll 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.TestInstance 11 | import java.io.File 12 | import java.io.FileOutputStream 13 | 14 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 15 | internal class PlayTest { 16 | 17 | 18 | private lateinit var api: GooglePlayAPI 19 | 20 | @BeforeAll 21 | @Test 22 | fun givenValidCreds_whenLogin_thenSuccess() = runBlockingTest { 23 | val username = System.getenv("PLAY_API_GOOGLE_USERNAME")!! 24 | val password = System.getenv("PLAY_API_GOOGLE_PASSWORD")!! 25 | 26 | val account = Play.login(username, password) 27 | // val account = testAccount 28 | account.should.not.`null` 29 | api = Play.getApi(account) 30 | } 31 | 32 | @Test 33 | fun givenInvalidCreds_whenLogin_thenError() = runBlockingTest { 34 | try { 35 | Play.login("", "") 36 | assert(false) 37 | } catch (e: ClientProtocolException) { 38 | assert(true) 39 | } 40 | } 41 | 42 | @Test 43 | fun givenValidPackageName_whenGetPackageDetails_thenSuccess() { 44 | val packageName = "com.theapache64.papercop" 45 | val details = api.details(packageName) 46 | details.docV2.docid.should.equal(packageName) 47 | } 48 | 49 | @Test 50 | fun givenValidPackageName_whenGetPackageDetails_thenSuccess2() { 51 | val packageName = "com.meesho.supply" 52 | val details = api.details(packageName) 53 | println(details.docV2) 54 | } 55 | 56 | @Test 57 | fun givenInvalidPackageName_whenGetPackageDetails_thenError() { 58 | val packageName = "" 59 | try { 60 | api.details(packageName) 61 | assert(false) 62 | } catch (e: GooglePlayException) { 63 | assert(true) 64 | } 65 | } 66 | 67 | @Test 68 | fun givenValidKeyword_whenSearch_thenSuccess() = runBlockingTest { 69 | val keyword = "WhatsApp" 70 | var serp = Play.search(keyword, api) 71 | serp.content.size.should.equal(34) // first page 72 | serp = Play.search(keyword, api, serp) 73 | serp.content.size.should.equal(68) // first page + second page 74 | } 75 | 76 | /** 77 | * Search test with pagination 78 | */ 79 | @Test 80 | fun givenValidKeyword_whenSearch_thenSuccessWithPagination() = runBlockingTest { 81 | val keyword = "WhatsApp" 82 | var serp = Play.search(keyword, api) 83 | 84 | repeat(10) { 85 | // Loading 10 pages 86 | serp = Play.search(keyword, api, serp) 87 | } 88 | 89 | serp.content.size.should.above(300) 90 | } 91 | 92 | @Test 93 | fun givenValidSmallPackageName_whenDownload_thenSuccess() { 94 | downloadApkAndTest("a.i") 95 | } 96 | 97 | @Test 98 | fun givenValidMediumPackageName_whenDownload_thenSuccess() { 99 | downloadApkAndTest("com.theapache64.papercop") 100 | } 101 | 102 | @Test 103 | fun `Download API level restricted app`() { 104 | val packageName = "com.truecaller" 105 | val downloadData = api.purchaseAndDeliver( 106 | packageName, 107 | 1153006, 108 | 1, 109 | ) 110 | println(downloadData) 111 | } 112 | 113 | 114 | /** 115 | * To download APK 116 | */ 117 | private fun downloadApkAndTest(packageName: String) { 118 | val apkFile = File("$packageName.apk") 119 | val details = api.details(packageName) 120 | val versionCode = details.docV2.details.appDetails.versionCode 121 | val downloadData = api.purchaseAndDeliver( 122 | packageName, 123 | versionCode, 124 | 1, 125 | ) 126 | downloadData.openApp().use { input -> 127 | FileOutputStream(apkFile).use { output -> 128 | input.copyTo(output) 129 | } 130 | } 131 | 132 | apkFile.exists().should.`true` 133 | apkFile.delete() // test finished, so deleting downloaded file 134 | } 135 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/github/theapache64/gpa/utils/CoroutinesTestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.github.theapache64.gpa.utils 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.runBlocking 5 | 6 | fun runBlockingTest(block: suspend (scope: CoroutineScope) -> Unit) = runBlocking { 7 | block(this) 8 | Unit 9 | } --------------------------------------------------------------------------------