├── .github └── workflows │ ├── presubmit.yml │ └── release.yml ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── gradle.xml ├── kotlinc.xml ├── misc.xml └── vcs.xml ├── LICENSE ├── README.md ├── art ├── app-icon │ ├── icon.icns │ ├── icon.ico │ ├── icon.iconset │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ └── kotlin-explorer.afphoto └── kotlin-explorer.png ├── build.gradle.kts ├── compose-desktop.pro ├── compose-stability.config ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── scripts ├── .gitignore ├── aarch64.json └── help-generator-arm.py ├── settings.gradle.kts ├── src ├── jvmMain │ ├── kotlin │ │ └── dev │ │ │ └── romainguy │ │ │ └── kotlin │ │ │ └── explorer │ │ │ ├── DependencyCache.kt │ │ │ ├── Disassembly.kt │ │ │ ├── DocumentChangeListener.kt │ │ │ ├── KotlinExplorer.kt │ │ │ ├── Logger.kt │ │ │ ├── Menu.kt │ │ │ ├── OS.kt │ │ │ ├── Paths.kt │ │ │ ├── PeekingIterator.kt │ │ │ ├── Process.kt │ │ │ ├── ProgressUpdater.kt │ │ │ ├── Regex.kt │ │ │ ├── Settings.kt │ │ │ ├── SourceTextArea.kt │ │ │ ├── Splitter.kt │ │ │ ├── State.kt │ │ │ ├── String.kt │ │ │ ├── Swing.kt │ │ │ ├── SyntaxTextArea.kt │ │ │ ├── Theme.kt │ │ │ ├── build │ │ │ ├── ByteCodeDecompiler.kt │ │ │ ├── DexCompiler.kt │ │ │ └── KolinCompiler.kt │ │ │ ├── bytecode │ │ │ └── ByteCodeParser.kt │ │ │ ├── code │ │ │ ├── Aarch64Docs.kt │ │ │ ├── Code.kt │ │ │ ├── CodeBuilder.kt │ │ │ ├── CodeContent.kt │ │ │ ├── CodeStyle.kt │ │ │ ├── CodeTextArea.kt │ │ │ ├── DataModels.kt │ │ │ ├── Documentation.kt │ │ │ ├── OpCodeDoc.kt │ │ │ └── SyntaxStyle.kt │ │ │ ├── dex │ │ │ └── DexDumpParser.kt │ │ │ └── oat │ │ │ └── OatDumpParser.kt │ └── resources │ │ ├── icons │ │ ├── done.svg │ │ ├── error.svg │ │ └── icon.ico │ │ └── themes │ │ ├── kotlin_explorer.xml │ │ └── kotlin_explorer_disassembly.xml └── jvmTest │ └── kotlin │ ├── dev │ └── romainguy │ │ └── kotlin │ │ └── explorer │ │ ├── bytecode │ │ └── ByteCodeParserTest.kt │ │ └── testing │ │ └── Builder.kt │ └── testData │ ├── Issue_45-Bytecode.expected │ ├── Issue_45.javap │ ├── Issue_45.kt │ ├── TryCatch-Bytecode.expected │ ├── TryCatch.javap │ └── TryCatch.kt └── token-makers ├── build.gradle.kts └── src └── main └── java └── dev └── romainguy └── kotlin └── explorer └── code ├── DexTokenMaker.flex ├── DexTokenMaker.java ├── KotlinTokenMaker.flex ├── KotlinTokenMaker.java ├── OatTokenMaker.flex └── OatTokenMaker.java /.github/workflows/presubmit.yml: -------------------------------------------------------------------------------- 1 | name: Presubmit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: macos-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | # TODO: replace this once https://github.com/actions/setup-java/pull/637 gets merged. 17 | - uses: gmitch215/setup-java@6d2c5e1f82f180ae79f799f0ed6e3e5efb4e664d 18 | with: 19 | distribution: 'jetbrains' 20 | java-version: 17 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | - uses: gradle/actions/setup-gradle@v3 24 | - run: ./gradlew build 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | # The only available image for x86 is macos-13-large 13 | # https://github.com/actions/runner-images?tab=readme-ov-file#available-images 14 | os: [ macos-13, macos-latest ] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | # TODO: replace this once https://github.com/actions/setup-java/pull/637 gets merged. 19 | - uses: gmitch215/setup-java@6d2c5e1f82f180ae79f799f0ed6e3e5efb4e664d 20 | with: 21 | distribution: 'jetbrains' 22 | java-version: 17 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | - uses: gradle/actions/setup-gradle@v3 26 | - run: ./gradlew build 27 | - uses: actions/upload-artifact@v4 28 | with: 29 | name: distribution-${{ matrix.os }} 30 | if-no-files-found: error 31 | path: | 32 | build/compose/binaries/main-release/dmg/kotlin-explorer-*.dmg 33 | 34 | release: 35 | runs-on: ubuntu-latest 36 | if: github.repository_owner == 'romainguy' 37 | needs: build 38 | permissions: 39 | contents: write 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/download-artifact@v4 43 | - name: Create release and upload dists 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | run: | 47 | gh release create "${{ github.ref_name }}" **/*.dmg -t "${{ github.ref_name }}" --draft --generate-notes 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/**/build/ 5 | 6 | ### IntelliJ IDEA ### 7 | .idea/modules.xml 8 | .idea/jarRepositories.xml 9 | .idea/compiler.xml 10 | .idea/uiDesigner.xml 11 | .idea/libraries/ 12 | .idea/artifacts/ 13 | *.iws 14 | *.iml 15 | *.ipr 16 | out/ 17 | !**/src/main/**/out/ 18 | !**/src/test/**/out/ 19 | 20 | ### Eclipse ### 21 | .apt_generated 22 | .classpath 23 | .factorypath 24 | .project 25 | .settings 26 | .springBeans 27 | .sts4-cache 28 | bin/ 29 | !**/src/main/**/bin/ 30 | !**/src/test/**/bin/ 31 | 32 | ### NetBeans ### 33 | /nbproject/private/ 34 | /nbbuild/ 35 | /dist/ 36 | /nbdist/ 37 | /.nb-gradle/ 38 | 39 | ### VS Code ### 40 | .vscode/ 41 | 42 | ### Mac OS ### 43 | .DS_Store 44 | /local.properties 45 | /.idea/AndroidProjectSystem.xml 46 | /.idea/inspectionProfiles/Project_Default.xml 47 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.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 | # Kotlin Explorer 2 | ![image](art/app-icon/icon.iconset/icon_256x256.png) 3 | 4 | Kotlin Explorer is a desktop tool to quickly and easily disassemble Kotlin code into: 5 | - Java bytecode 6 | - Android DEX bytecode 7 | - Android OAT assembly 8 | 9 | After launching Kotlin Explorer, type valid Kotlin code in the left pane, then click 10 | *Build > Build & Disassemble* or use `Cmd-Shift-D` on macOS, `Ctrl-Shift-D` 11 | on Linux and Windows. 12 | 13 | By default, the middle pane will show the Android DEX bytecode, and the right panel 14 | the native assembly resulting from ahead of time compilation (AOT). You can control 15 | which panels are visible using the *View* menu. 16 | 17 | ![./art/kotlin-explorer.png](./art/kotlin-explorer.png) 18 | 19 | # Features 20 | 21 | - *Build > Optimize with R8*: turn on R8 optimizations. Turning this on will affect the 22 | ability to see corresponding source line numbers in the byte code and DEX outputs. 23 | - *View > Sync Lines*: synchronize the current line in the source, byte code, and DEX 24 | panels. This feature may require R8 optimizations to be turned off to work properly. 25 | - *View > Presentation Mode*: increase the font size to make the content more visible 26 | when projected. 27 | - *Build > Build on Startup*: to automatically launch a compilation when launching the 28 | app. 29 | - *Build > Run*: compile the Kotlin source code and run it locally. Any output is sent 30 | to the logs panel. 31 | - Clicking a jump instruction will show an arrow to the jump destination. 32 | - Shows the number of instructions and branches per method. 33 | - Click a disassembled instruction or register to highlight all occurrences. 34 | - Inline aarch64 (ARM 64 bit) documentation. Use *View > Show Logs & Documentation*. 35 | 36 | # Kotlin Explorer and R8 37 | 38 | You can use *Build > Optimize with R8* to optimize the compiled code with the R8 tool. 39 | By default, all public classes/members/etc. will be kept, allowing you to analyze them 40 | in the disassembly panels. 41 | 42 | However, keeping everything is not representative of what R8 will do on an actual 43 | application so you can disable that feature, and instead use the `@Keep` annotation 44 | to choose an entry point leading to the desired disassembly. You can also create a 45 | `fun main()` entry point and call your code from there. Be careful to not use constants 46 | when calling other methods as this may lead to aggressive optimization by R8. 47 | 48 | # Running Kotlin Explorer 49 | 50 | Run Kotlin Explorer with `./gradlew jvmRun`. 51 | 52 | Kotlin Explorer needs to be told where to find the Android SDK and the Kotlin compiler. 53 | Unless you've set `$ANDROID_HOME` and `$KOTLIN_HOME` properly, Kotlin Explorer will ask 54 | you to enter the path to those directories. 55 | 56 | For `$ANDROID_HOME`, use the path to the root of the Android SDK (directory containing 57 | `build-tools/`, `platform-tools/`, etc.). Android Studio for macOS stores this in 58 | `$HOME/Library/Android/sdk`. 59 | 60 | For `$KOTLIN_HOME`, use the path to the root of your 61 | [Kotlin installation](https://kotlinlang.org/docs/command-line.html). This directory 62 | should contain `bin/kotlinc` and `lib/kotlin-stdlib-*.jar` for instance. 63 | 64 | Kotlin explorer also requires `java` and `javap` to be in your `$PATH`. 65 | 66 | > [!IMPORTANT] 67 | > DEX bytecode and OAT assembly will only be displayed if you have an Android 68 | > device or emulator that can be successfully reached via `adb`. The device 69 | > must be recent enough to host the `oatdump` tool on its system image. 70 | 71 | # License 72 | 73 | Please see [LICENSE](./LICENSE). 74 | -------------------------------------------------------------------------------- /art/app-icon/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.icns -------------------------------------------------------------------------------- /art/app-icon/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.ico -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_128x128.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_16x16.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_256x256.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_32x32.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_512x512.png -------------------------------------------------------------------------------- /art/app-icon/icon.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/icon.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /art/app-icon/kotlin-explorer.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/app-icon/kotlin-explorer.afphoto -------------------------------------------------------------------------------- /art/kotlin-explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/art/kotlin-explorer.png -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 4 | import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask 5 | import kotlin.io.path.listDirectoryEntries 6 | 7 | plugins { 8 | alias(libs.plugins.kotlin.multiplatform) 9 | alias(libs.plugins.jetbrains.compose) 10 | alias(libs.plugins.compose.compiler) 11 | } 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 17 | maven("https://packages.jetbrains.team/maven/p/kpm/public/") 18 | } 19 | 20 | version = "1.6.6" 21 | val baseName = "Kotlin Explorer" 22 | 23 | kotlin { 24 | jvm { 25 | @Suppress("OPT_IN_USAGE") 26 | mainRun { 27 | mainClass = "dev.romainguy.kotlin.explorer.KotlinExplorerKt" 28 | } 29 | } 30 | 31 | jvmToolchain { 32 | vendor = JvmVendorSpec.JETBRAINS 33 | languageVersion = JavaLanguageVersion.of(17) 34 | } 35 | 36 | sourceSets { 37 | val jvmMain by getting { 38 | dependencies { 39 | implementation(compose.desktop.currentOs) { 40 | exclude(group = "org.jetbrains.compose.material") 41 | } 42 | implementation(compose.components.resources) 43 | implementation(libs.collection) 44 | implementation(libs.compose.backhandler) 45 | implementation(libs.compose.material3) 46 | implementation(libs.compose.splitpane) 47 | implementation(libs.jewel) 48 | implementation(libs.jewel.decorated) 49 | implementation(libs.jewel.markdown.core) 50 | implementation(libs.jewel.markdown.intUiStandaloneStyling) 51 | implementation(libs.jna) 52 | implementation(libs.lifecycle) 53 | implementation(libs.lifecycle.compose) 54 | implementation(libs.lifecycle.viewmodel) 55 | implementation(libs.lifecycle.viewmodel.compose) 56 | implementation(libs.skiko.mac) 57 | implementation(libs.skiko.linux) 58 | implementation(libs.rsyntaxtextarea) 59 | implementation(libs.rstaui) 60 | implementation(project(":token-makers")) 61 | } 62 | } 63 | 64 | val jvmTest by getting { 65 | dependencies { 66 | implementation(libs.junit4) 67 | implementation(libs.kotlin.test) 68 | implementation(libs.truth) 69 | } 70 | } 71 | } 72 | } 73 | 74 | composeCompiler { 75 | stabilityConfigurationFiles = listOf(layout.projectDirectory.file("compose-stability.config")) 76 | reportsDestination = layout.buildDirectory.dir("compose-compiler") 77 | } 78 | 79 | compose.desktop { 80 | application { 81 | mainClass = "dev.romainguy.kotlin.explorer.KotlinExplorerKt" 82 | buildTypes.release.proguard { 83 | configurationFiles.from(project.file("compose-desktop.pro")) 84 | } 85 | nativeDistributions { 86 | modules("jdk.unsupported") 87 | 88 | targetFormats(TargetFormat.Dmg, TargetFormat.Exe) 89 | 90 | packageVersion = version.toString() 91 | packageName = baseName 92 | description = baseName 93 | vendor = "Romain Guy" 94 | licenseFile = rootProject.file("LICENSE") 95 | 96 | macOS { 97 | dockName = "Kotlin Explorer" 98 | iconFile = file("art/app-icon/icon.icns") 99 | bundleID = "dev.romainguy.kotlin.explorer" 100 | } 101 | 102 | windows { 103 | menuGroup = "Kotlin Explorer" 104 | iconFile = file("art/app-icon/icon.ico") 105 | } 106 | } 107 | } 108 | } 109 | 110 | val currentArch: String = when (val osArch = System.getProperty("os.arch")) { 111 | "x86_64", "amd64" -> "x64" 112 | "aarch64" -> "arm64" 113 | else -> error("Unsupported OS arch: $osArch") 114 | } 115 | 116 | /** 117 | * TODO: workaround for https://github.com/JetBrains/compose-multiplatform/issues/4976. 118 | */ 119 | val renameDmg by tasks.registering(Copy::class) { 120 | group = "distribution" 121 | description = "Rename the DMG file" 122 | 123 | val packageReleaseDmg = tasks.named("packageReleaseDmg") 124 | // build/compose/binaries/main-release/dmg/*.dmg 125 | val fromFile = packageReleaseDmg.map { task -> 126 | task.destinationDir.asFile.get().toPath().listDirectoryEntries("$baseName*.dmg").single() 127 | } 128 | 129 | from(fromFile) 130 | into(fromFile.map { it.parent }) 131 | rename { 132 | "kotlin-explorer-$currentArch-$version.dmg" 133 | } 134 | } 135 | 136 | tasks.assemble { 137 | dependsOn(renameDmg) 138 | } 139 | -------------------------------------------------------------------------------- /compose-desktop.pro: -------------------------------------------------------------------------------- 1 | -dontoptimize 2 | 3 | -dontwarn androidx.compose.desktop.DesktopTheme* 4 | -dontwarn kotlinx.datetime.** 5 | 6 | -keep class dev.romainguy.kotlin.explorer.code.*TokenMarker { *; } 7 | -dontnote dev.romainguy.kotlin.explorer.code.*TokenMarker 8 | 9 | -keep class org.fife.** { *; } 10 | -dontnote org.fife.** 11 | 12 | -keep class sun.misc.Unsafe { *; } 13 | -dontnote sun.misc.Unsafe 14 | 15 | -keep class com.jetbrains.JBR* { *; } 16 | -dontnote com.jetbrains.JBR* 17 | 18 | -keep class com.sun.jna** { *; } 19 | -dontnote com.sun.jna** 20 | 21 | -keep class androidx.compose.ui.input.key.KeyEvent_desktopKt { *; } 22 | -dontnote androidx.compose.ui.input.key.KeyEvent_desktopKt 23 | 24 | -keep class androidx.compose.ui.input.key.KeyEvent_skikoKt { *; } 25 | -dontnote androidx.compose.ui.input.key.KeyEvent_skikoKt 26 | -dontwarn androidx.compose.ui.input.key.KeyEvent_skikoKt 27 | 28 | -dontnote org.jetbrains.jewel.intui.markdown.standalone.styling.extensions.** 29 | -dontwarn org.jetbrains.jewel.intui.markdown.standalone.styling.extensions.** 30 | 31 | -dontnote org.jetbrains.jewel.foundation.lazy.** 32 | -dontwarn org.jetbrains.jewel.foundation.lazy.** 33 | 34 | -dontnote org.jetbrains.jewel.foundation.util.** 35 | -dontwarn org.jetbrains.jewel.foundation.util.** 36 | 37 | -dontnote org.jetbrains.jewel.window.utils.** 38 | -dontwarn org.jetbrains.jewel.window.utils.** 39 | -------------------------------------------------------------------------------- /compose-stability.config: -------------------------------------------------------------------------------- 1 | androidx.collection.* 2 | 3 | java.nio.file.Path 4 | 5 | kotlin.collections.* 6 | 7 | kotlinx.coroutines.CoroutineScope 8 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | collection = "1.5.0" 3 | compose = "1.8.0" 4 | jewel = "1.0.0-SNAPSHOT" 5 | jna = "5.17.0" 6 | junit4 = "4.13.2" 7 | kotlin = "2.1.20" 8 | lifecycle = "2.8.4" 9 | rstaui = "3.3.1" 10 | rsyntaxtextarea="3.4.0" 11 | skiko="0.9.16" 12 | truth = "1.4.2" 13 | 14 | [libraries] 15 | collection = { group = "androidx.collection", name = "collection", version.ref = "collection" } 16 | compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3-desktop", version.ref = "compose" } 17 | compose-splitpane = { group = "org.jetbrains.compose.components", name = "components-splitpane-desktop", version.ref = "compose" } 18 | compose-backhandler = { group = "org.jetbrains.compose.ui", name = "ui-backhandler", version.ref = "compose" } 19 | jewel = { group = "org.jetbrains.jewel", name = "jewel-int-ui-standalone-243", version.ref = "jewel" } 20 | jewel-decorated = { group = "org.jetbrains.jewel", name = "jewel-int-ui-decorated-window-243", version.ref = "jewel" } 21 | jewel-markdown-core = { group = "org.jetbrains.jewel", name = "jewel-markdown-core-243", version.ref = "jewel" } 22 | jewel-markdown-intUiStandaloneStyling = { group = "org.jetbrains.jewel", name = "jewel-markdown-int-ui-standalone-styling-243", version.ref = "jewel" } 23 | jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } 24 | junit4 = { group = "junit", name = "junit", version.ref = "junit4" } 25 | kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } 26 | lifecycle = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycle" } 27 | lifecycle-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } 28 | lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycle" } 29 | lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } 30 | rstaui = { group = "com.fifesoft", name = "rstaui", version.ref = "rstaui" } 31 | rsyntaxtextarea = { group = "com.fifesoft", name = "rsyntaxtextarea", version.ref = "rsyntaxtextarea" } 32 | skiko-linux = { group = "org.jetbrains.skiko", name = "skiko-awt-runtime-linux-x64", version.ref = "skiko" } 33 | skiko-mac = { group = "org.jetbrains.skiko", name = "skiko-awt-runtime-macos-arm64", version.ref = "skiko" } 34 | truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } 35 | 36 | [plugins] 37 | jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose" } 38 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 39 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 40 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/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-8.13-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original 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 POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | aarch64-docs/ 2 | bs4-env/ 3 | *.kt 4 | -------------------------------------------------------------------------------- /scripts/aarch64.json: -------------------------------------------------------------------------------- 1 | { 2 | "archive": { 3 | "url": "https://developer.arm.com/-/media/developer/products/architecture/armv9-a-architecture/2023-03/ISA_A64_xml_A_profile-2023-03.tar.gz", 4 | "name": "ISA_A64_xml_A_profile-2023-03.tar.gz", 5 | "subdir": "ISA_A64_xml_A_profile-2023-03" 6 | }, 7 | "documentation": "https://developer.arm.com/documentation/ddi0602/2024-06/Base-Instructions/", 8 | "isa": "aarch64" 9 | } 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | gradlePluginPortal() 5 | mavenCentral() 6 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 7 | } 8 | } 9 | 10 | plugins { 11 | // Ensure JBR vendor is configured on CI, see https://github.com/actions/setup-java/issues/399. 12 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 13 | } 14 | 15 | rootProject.name = "kotlin-explorer" 16 | 17 | include("tests") 18 | include("token-makers") 19 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/DependencyCache.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2025 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package dev.romainguy.kotlin.explorer 17 | 18 | import java.io.FileNotFoundException 19 | import java.net.URL 20 | import java.nio.file.Path 21 | import java.nio.file.StandardOpenOption.* 22 | import java.util.zip.ZipInputStream 23 | import kotlin.io.path.createParentDirectories 24 | import kotlin.io.path.notExists 25 | import kotlin.io.path.outputStream 26 | 27 | private const val MAVEN_REPO = "https://repo1.maven.org/maven2" 28 | private const val GOOGLE_REPO = "https://maven.google.com" 29 | private val repos = listOf(MAVEN_REPO, GOOGLE_REPO) 30 | 31 | private const val BASE_PATH = "%1\$s/%2\$s/%3\$s/%2\$s-%3\$s" 32 | 33 | class DependencyCache(private val root: Path) { 34 | 35 | fun getDependency(group: String, name: String, version: String, onOutput: (String) -> Unit): Path { 36 | val basePath = BASE_PATH.format(group.replace('.', '/'), name, version) 37 | val dst = root.resolve("$basePath.jar") 38 | if (dst.notExists()) { 39 | onOutput("Downloading artifact $group:$name:$version") 40 | repos.forEach { 41 | if (getDependency(it, basePath, dst, onOutput)) { 42 | return dst 43 | } 44 | } 45 | onOutput("Could not find artifact $group:$name:$version") 46 | } 47 | return dst 48 | } 49 | 50 | /** 51 | * Try to download a jar from a repo. 52 | * 53 | * First tries to download the `jar` file directly. If the `jar` is not found, will try to download an `aar` and 54 | * extract the `classes.ja` file from it. 55 | * 56 | * @return true if the repo owns the artifact. 57 | */ 58 | private fun getDependency(repo: String, basePath: String, dst: Path, onOutput: (String) -> Unit): Boolean { 59 | try { 60 | // Does the artifact exist in repo? 61 | URL("$repo/$basePath.pom").openStream().reader().close() 62 | } catch (_: FileNotFoundException) { 63 | return false 64 | } 65 | dst.createParentDirectories() 66 | dst.outputStream(CREATE, WRITE, TRUNCATE_EXISTING).use { outputStream -> 67 | try { 68 | URL("$repo/$basePath.jar").openStream().use { 69 | it.copyTo(outputStream) 70 | } 71 | } catch (_: FileNotFoundException) { 72 | val aar = "$repo/$basePath.aar" 73 | 74 | ZipInputStream(URL(aar).openStream()).use { 75 | while (true) { 76 | val entry = it.nextEntry 77 | if (entry == null) { 78 | onOutput("Could not find 'classes.jar' in $aar") 79 | return true 80 | } 81 | if (entry.name == "classes.jar") { 82 | it.copyTo(outputStream) 83 | break 84 | } 85 | } 86 | } 87 | 88 | } 89 | } 90 | return true 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Disassembly.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import androidx.compose.ui.text.AnnotatedString 20 | import androidx.compose.ui.text.SpanStyle 21 | import androidx.compose.ui.text.buildAnnotatedString 22 | import androidx.compose.ui.text.withStyle 23 | import dev.romainguy.kotlin.explorer.build.ByteCodeDecompiler 24 | import dev.romainguy.kotlin.explorer.build.DexCompiler 25 | import dev.romainguy.kotlin.explorer.build.KotlinCompiler 26 | import dev.romainguy.kotlin.explorer.bytecode.ByteCodeParser 27 | import dev.romainguy.kotlin.explorer.code.CodeContent 28 | import dev.romainguy.kotlin.explorer.code.ISA 29 | import dev.romainguy.kotlin.explorer.dex.DexDumpParser 30 | import dev.romainguy.kotlin.explorer.oat.OatDumpParser 31 | import kotlinx.coroutines.* 32 | import java.io.File 33 | import java.nio.file.Files 34 | import java.nio.file.Path 35 | import kotlin.io.path.extension 36 | import kotlin.io.path.isDirectory 37 | 38 | private const val TotalDisassemblySteps = 7 39 | private const val TotalRunSteps = 2 40 | 41 | private val byteCodeDecompiler = ByteCodeDecompiler() 42 | private val byteCodeParser = ByteCodeParser() 43 | private val dexDumpParser = DexDumpParser() 44 | private val oatDumpParser = OatDumpParser() 45 | 46 | suspend fun buildAndRun( 47 | toolPaths: ToolPaths, 48 | kotlinOnlyConsumers: Boolean, 49 | compilerFlags: String, 50 | settingsDirectory: Path, 51 | composeVersion: String, 52 | source: String, 53 | onLogs: (AnnotatedString) -> Unit, 54 | onStatusUpdate: (String, Float) -> Unit 55 | ) = coroutineScope { 56 | val ui = currentCoroutineContext() 57 | 58 | launch(Dispatchers.IO) { 59 | val updater = ProgressUpdater(TotalRunSteps, onStatusUpdate) 60 | try { 61 | updater.addJob(launch(ui) { updater.update("Compiling Kotlin…") }) 62 | 63 | val directory = toolPaths.tempDirectory 64 | cleanupClasses(directory) 65 | 66 | val path = directory.resolve("KotlinExplorer.kt") 67 | Files.writeString(path, source) 68 | writeSupportFiles(directory) 69 | 70 | val kotlinc = KotlinCompiler(toolPaths, settingsDirectory, directory).compile(kotlinOnlyConsumers, compilerFlags, composeVersion, path) 71 | 72 | if (kotlinc.exitCode != 0) { 73 | withContext(ui) { 74 | onLogs(showError(kotlinc.output.replace(path.parent.toString() + "/", ""))) 75 | updater.advance("Error compiling Kotlin", 2) 76 | } 77 | return@launch 78 | } 79 | 80 | withContext(ui) { updater.advance("Running…") } 81 | 82 | val java = process( 83 | *buildJavaCommand(toolPaths), 84 | directory = directory 85 | ) 86 | 87 | withContext(ui) { 88 | onLogs(showLogs(java.output)) 89 | val status = if (java.exitCode != 0) "Error running code" else "Run completed" 90 | updater.advance(status) 91 | } 92 | 93 | updater.addJob(launch(ui) { updater.update("Ready") }) 94 | } finally { 95 | withContext(ui) { updater.finish() } 96 | } 97 | } 98 | } 99 | 100 | suspend fun buildAndDisassemble( 101 | toolPaths: ToolPaths, 102 | source: String, 103 | kotlinOnlyConsumers: Boolean, 104 | compilerFlags: String, 105 | r8rules: String, 106 | minApi: Int, 107 | settingsDirectory: Path, 108 | composeVersion: String, 109 | instructionSets: Map, 110 | onByteCode: (CodeContent) -> Unit, 111 | onDex: (CodeContent) -> Unit, 112 | onOat: (CodeContent) -> Unit, 113 | onLogs: (AnnotatedString) -> Unit, 114 | onStatusUpdate: (String, Float) -> Unit, 115 | optimize: Boolean, 116 | keepEverything: Boolean 117 | ) = coroutineScope { 118 | val ui = currentCoroutineContext() 119 | 120 | onLogs(showLogs("")) 121 | 122 | launch(Dispatchers.IO) { 123 | val updater = ProgressUpdater(TotalDisassemblySteps, onStatusUpdate) 124 | try { 125 | updater.addJob(launch(ui) { updater.update("Compiling and disassembling…") }) 126 | 127 | val directory = toolPaths.tempDirectory 128 | cleanupClasses(directory) 129 | 130 | val path = directory.resolve("KotlinExplorer.kt") 131 | Files.writeString(path, source) 132 | writeSupportFiles(directory) 133 | 134 | val kotlinc = KotlinCompiler(toolPaths, settingsDirectory, directory).compile(kotlinOnlyConsumers, compilerFlags, composeVersion, path) 135 | 136 | if (kotlinc.exitCode != 0) { 137 | updater.addJob(launch(ui) { 138 | onLogs(showError(kotlinc.output.replace(path.parent.toString() + "/", ""))) 139 | updater.advance("Error compiling Kotlin", updater.steps) 140 | }) 141 | return@launch 142 | } 143 | updater.addJob(launch(ui) { updater.advance("Kotlin compiled") }) 144 | 145 | if (instructionSets.getOrDefault(ISA.ByteCode, true)) { 146 | updater.addJob(launch { 147 | val javap = byteCodeDecompiler.decompile(directory) 148 | withContext(ui) { 149 | val status = if (javap.exitCode != 0) { 150 | onLogs(showError(javap.output)) 151 | "Error Disassembling Java ByteCode" 152 | } else { 153 | onByteCode(byteCodeParser.parse(javap.output)) 154 | "Disassembled Java ByteCode" 155 | } 156 | updater.advance(status) 157 | } 158 | }) 159 | } else { 160 | launch(ui) { updater.advance("") } 161 | } 162 | 163 | val dexCompiler = DexCompiler(toolPaths, directory, r8rules, minApi) 164 | 165 | val dex = dexCompiler.buildDex(optimize, keepEverything) 166 | 167 | if (dex.exitCode != 0) { 168 | updater.addJob(launch(ui) { 169 | onLogs(showError(dex.output)) 170 | updater.advance("Error creating DEX", updater.steps) 171 | }) 172 | return@launch 173 | } 174 | updater.addJob(launch(ui) { 175 | updater.advance(if (optimize) "Optimized DEX with R8" else "Compiled DEX with D8") 176 | }) 177 | 178 | if (instructionSets.getOrDefault(ISA.Dex, true)) { 179 | updater.addJob(launch { 180 | val dexdump = dexCompiler.dumpDex() 181 | withContext(ui) { 182 | val status = if (dexdump.exitCode != 0) { 183 | onLogs(showError(dexdump.output)) 184 | "Error creating DEX dump" 185 | } else { 186 | onDex(dexDumpParser.parse(dexdump.output)) 187 | "Created DEX dump" 188 | } 189 | updater.advance(status) 190 | } 191 | }) 192 | } else { 193 | launch(ui) { updater.advance("") } 194 | } 195 | 196 | if (!instructionSets.getOrDefault(ISA.Oat, true)) { 197 | updater.waitForJobs() 198 | withContext(ui) { 199 | updater.skipToEnd("Ready") 200 | } 201 | return@launch 202 | } 203 | 204 | val push = process( 205 | toolPaths.adb.toString(), 206 | "push", 207 | "classes.dex", 208 | "/sdcard/classes.dex", 209 | directory = directory 210 | ) 211 | 212 | if (push.exitCode != 0) { 213 | updater.addJob(launch(ui) { 214 | onLogs(showError(push.output)) 215 | updater.advance("Error pushing code to device", updater.steps) 216 | }) 217 | return@launch 218 | } 219 | 220 | updater.addJob(launch(ui) { updater.advance("Pushed code to device…") }) 221 | 222 | val dex2oat = process( 223 | toolPaths.adb.toString(), 224 | "shell", 225 | "dex2oat", 226 | "--dex-file=/sdcard/classes.dex", 227 | "--oat-file=/sdcard/classes.oat", 228 | directory = directory 229 | ) 230 | 231 | if (dex2oat.exitCode != 0) { 232 | updater.addJob(launch(ui) { 233 | onLogs(showError(dex2oat.output)) 234 | updater.advance("Error compiling OAT", updater.steps) 235 | }) 236 | return@launch 237 | } 238 | 239 | updater.addJob(launch(ui) { updater.advance("Disassembling OAT…") }) 240 | 241 | val oatdump = process( 242 | toolPaths.adb.toString(), 243 | "shell", 244 | "oatdump", 245 | "--oat-file=/sdcard/classes.oat", 246 | directory = directory 247 | ) 248 | 249 | updater.addJob(launch(ui) { onOat(oatDumpParser.parse(oatdump.output)) }) 250 | 251 | val status = if (oatdump.exitCode != 0) { 252 | onLogs(showError(oatdump.output)) 253 | "Error creating oat dump" 254 | } else { 255 | "Created oat dump" 256 | } 257 | withContext(ui) { updater.advance(status) } 258 | 259 | updater.addJob(launch(ui) { updater.update("Ready") }) 260 | } finally { 261 | withContext(ui) { updater.finish() } 262 | } 263 | } 264 | } 265 | 266 | private fun buildJavaCommand(toolPaths: ToolPaths): Array { 267 | val classpath = toolPaths.kotlinLibs.joinToString(File.pathSeparator) { jar -> jar.toString() } + 268 | File.pathSeparator + toolPaths.platform + 269 | File.pathSeparator + "." 270 | 271 | val command = mutableListOf( 272 | "java", 273 | "-classpath", 274 | classpath, 275 | "KotlinExplorerKt" 276 | ) 277 | return command.toTypedArray() 278 | } 279 | 280 | private fun cleanupClasses(directory: Path) { 281 | Files 282 | .list(directory) 283 | .forEach { path -> 284 | if (path.extension == "class") { 285 | path.toFile().delete() 286 | } else if (path.isDirectory()) { 287 | path.toFile().deleteRecursively() 288 | } 289 | } 290 | } 291 | 292 | private fun writeSupportFiles(directory: Path) { 293 | Files.writeString( 294 | directory.resolve("NeverInline.kt"), 295 | """ 296 | @file:OptIn(ExperimentalMultiplatform::class) 297 | 298 | package dalvik.annotation.optimization 299 | 300 | @Retention(AnnotationRetention.BINARY) 301 | @Target(AnnotationTarget.CONSTRUCTOR, AnnotationTarget.FUNCTION) 302 | public annotation class NeverInline() 303 | """.trimIndent() 304 | ) 305 | 306 | Files.writeString( 307 | directory.resolve("Keep.kt"), 308 | """ 309 | import java.lang.annotation.ElementType.ANNOTATION_TYPE 310 | import java.lang.annotation.ElementType.CONSTRUCTOR 311 | import java.lang.annotation.ElementType.FIELD 312 | import java.lang.annotation.ElementType.METHOD 313 | import java.lang.annotation.ElementType.PACKAGE 314 | import java.lang.annotation.ElementType.TYPE 315 | 316 | @Retention(AnnotationRetention.BINARY) 317 | @Target( 318 | AnnotationTarget.FILE, 319 | AnnotationTarget.ANNOTATION_CLASS, 320 | AnnotationTarget.CLASS, 321 | AnnotationTarget.ANNOTATION_CLASS, 322 | AnnotationTarget.CONSTRUCTOR, 323 | AnnotationTarget.FUNCTION, 324 | AnnotationTarget.PROPERTY_GETTER, 325 | AnnotationTarget.PROPERTY_SETTER, 326 | AnnotationTarget.FIELD 327 | ) 328 | @Suppress("DEPRECATED_JAVA_ANNOTATION", "SupportAnnotationUsage") 329 | @java.lang.annotation.Target(PACKAGE, TYPE, ANNOTATION_TYPE, CONSTRUCTOR, METHOD, FIELD) 330 | public annotation class Keep 331 | """.trimIndent() 332 | ) 333 | } 334 | 335 | private fun showError(error: String) = buildAnnotatedString { 336 | withStyle(SpanStyle(ErrorColor)) { 337 | append(error) 338 | } 339 | } 340 | 341 | private fun showLogs(logs: String) = AnnotatedString(logs) 342 | 343 | internal val BuiltInKotlinClass = Regex("^(kotlin|kotlinx|java|javax|org\\.(intellij|jetbrains))\\..+") 344 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/DocumentChangeListener.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import javax.swing.event.DocumentEvent 20 | import javax.swing.event.DocumentListener 21 | 22 | /** 23 | * A [DocumentListener] that takes a single lambda that's invoked on any change 24 | */ 25 | class DocumentChangeListener(private val block: (DocumentEvent) -> Unit) : DocumentListener { 26 | override fun insertUpdate(event: DocumentEvent) { 27 | block(event) 28 | } 29 | 30 | override fun removeUpdate(event: DocumentEvent) { 31 | block(event) 32 | } 33 | 34 | override fun changedUpdate(event: DocumentEvent) { 35 | block(event) 36 | } 37 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Logger.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | private const val Debug = 1 20 | private const val Warning = 2 21 | 22 | object Logger { 23 | private val level = System.getenv("KOTLIN_EXPLORER_LOG")?.toIntOrNull() ?: 0 24 | 25 | fun debug(message: String) { 26 | if (level >= Debug) { 27 | println("Debug: $message") 28 | } 29 | } 30 | 31 | fun warn(message: String) { 32 | if (level >= Warning) { 33 | println("Warning: $message") 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Menu.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("FunctionName") 18 | 19 | package dev.romainguy.kotlin.explorer 20 | 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.ui.input.key.Key 23 | import androidx.compose.ui.input.key.KeyShortcut 24 | import androidx.compose.ui.window.MenuScope 25 | import kotlin.reflect.KMutableProperty0 26 | 27 | /** Convenience class handles `Ctrl <-> Meta` modifies */ 28 | sealed class Shortcut( 29 | private val key: Key, 30 | private val isShift: Boolean, 31 | private val isCtrl: Boolean, 32 | private val isAlt: Boolean = false, 33 | private val metaOnMac: Boolean = true 34 | ) { 35 | class Ctrl(key: Key) : Shortcut(key, isCtrl = true, isShift = false) 36 | class CtrlAlt(key: Key) : Shortcut(key, isCtrl = true, isShift = false, isAlt = true) 37 | class CtrlOnly(key: Key) : Shortcut(key, isCtrl = true, isShift = false, metaOnMac = false) 38 | class CtrlShift(key: Key) : Shortcut(key, isCtrl = true, isShift = true) 39 | 40 | fun asKeyShortcut() = 41 | KeyShortcut( 42 | key = key, 43 | ctrl = isCtrl && (!isMac || !metaOnMac), 44 | shift = isShift, 45 | meta = isCtrl && isMac && metaOnMac, 46 | alt = isAlt 47 | ) 48 | } 49 | 50 | @Composable 51 | fun MenuScope.MenuCheckboxItem( 52 | text: String, 53 | shortcut: Shortcut?, 54 | property: KMutableProperty0, 55 | onCheckedChanged: (Boolean) -> Unit = {} 56 | ) { 57 | CheckboxItem(text, property.get(), shortcut = shortcut?.asKeyShortcut(), onCheckedChange = { 58 | property.set(it) 59 | onCheckedChanged(it) 60 | }) 61 | } 62 | 63 | @Composable 64 | fun MenuScope.MenuItem(text: String, shortcut: Shortcut, onClick: () -> Unit, enabled: Boolean = true) { 65 | Item(text, enabled = enabled, shortcut = shortcut.asKeyShortcut(), onClick = onClick) 66 | } 67 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/OS.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | val isWindows: Boolean = System.getProperty("os.name").lowercase().startsWith("win") 20 | val isMac: Boolean = System.getProperty("os.name").lowercase().startsWith("mac") 21 | val isLinux = !isWindows && !isMac 22 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Paths.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import java.nio.file.Files 20 | import java.nio.file.Path 21 | import kotlin.io.path.* 22 | 23 | class ToolPaths(settingsDirectory: Path, androidHome: Path, kotlinHome: Path) { 24 | constructor(settingsDirectory: Path, androidHome: String, kotlinHome: String) : this( 25 | settingsDirectory, 26 | Path.of(androidHome), 27 | Path.of(kotlinHome) 28 | ) 29 | val tempDirectory = Files.createTempDirectory("kotlin-explorer")!! 30 | val platform: Path 31 | val d8: Path 32 | val adb: Path = androidHome.resolve(if (isWindows) "platform-tools/adb.exe" else "platform-tools/adb") 33 | val dexdump: Path 34 | val kotlinc: Path 35 | val kotlinLibs: List 36 | val sourceFile: Path 37 | 38 | var isValid: Boolean = false 39 | private set 40 | var isAndroidHomeValid: Boolean = false 41 | private set 42 | var isKotlinHomeValid: Boolean = false 43 | private set 44 | 45 | init { 46 | val buildToolsDirectory = androidHome.resolve("build-tools") 47 | .listIfExists() 48 | .maxByOrNull { it.pathString } 49 | ?: androidHome 50 | d8 = System.getenv("D8_PATH")?.toPath() ?: buildToolsDirectory.resolve("lib/d8.jar") 51 | dexdump = buildToolsDirectory.resolve(if (isWindows) "dexdump.exe" else "dexdump") 52 | 53 | val platformsDirectory = androidHome.resolve("platforms") 54 | .listIfExists() 55 | .maxByOrNull { it.pathString } 56 | ?: androidHome 57 | platform = platformsDirectory.resolve("android.jar") 58 | 59 | kotlinc = kotlinHome.resolve(if (isWindows) "bin/kotlinc.bat" else "bin/kotlinc") 60 | 61 | val lib = kotlinHome.resolveFirstExistsOrFirst("lib", "libexec/lib") 62 | kotlinLibs = listOf( 63 | lib.resolve("kotlin-stdlib-jdk8.jar"), 64 | lib.resolve("kotlin-stdlib.jar"), 65 | lib.resolve("kotlin-annotations-jvm.jar"), 66 | lib.resolve("kotlinx-coroutines-core-jvm.jar"), 67 | lib.listIfExists() 68 | .filter { path -> path.extension == "jar" && path.name.startsWith("annotations-") } 69 | .maxByOrNull { it.pathString } 70 | ?: lib.resolve("annotations.jar") 71 | ) 72 | 73 | sourceFile = settingsDirectory.resolve("source-code.kt") 74 | 75 | isAndroidHomeValid = adb.exists() && d8.exists() && dexdump.exists() 76 | isKotlinHomeValid = kotlinc.exists() 77 | isValid = adb.exists() && d8.exists() && dexdump.exists() && kotlinc.exists() 78 | } 79 | } 80 | 81 | private fun Path.listIfExists() = if (exists()) listDirectoryEntries() else emptyList() 82 | 83 | private fun String.toPath() = Path.of(this) 84 | 85 | private fun Path.resolveFirstExistsOrFirst(vararg others: String): Path { 86 | for (other in others) { 87 | val path = resolve(other) 88 | if (path.exists()) { 89 | return path 90 | } 91 | } 92 | 93 | return resolve(others.first()) 94 | } 95 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/PeekingIterator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | /** Based on Guava PeekingIterator */ 20 | class PeekingIterator(private val iterator: Iterator) : Iterator { 21 | private var peekedElement: E? = null 22 | 23 | override fun hasNext(): Boolean { 24 | return peekedElement != null || iterator.hasNext() 25 | } 26 | 27 | override fun next(): E { 28 | val element = peekedElement ?: return iterator.next() 29 | peekedElement = null 30 | return element 31 | } 32 | 33 | fun peek(): E { 34 | return peekedElement.takeIf { it != null } ?: iterator.next().also { peekedElement = it } 35 | } 36 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Process.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import kotlinx.coroutines.* 20 | import kotlinx.coroutines.flow.asFlow 21 | import kotlinx.coroutines.flow.map 22 | import kotlinx.coroutines.flow.toList 23 | import java.nio.file.Path 24 | 25 | class ProcessResult( 26 | val exitCode: Int, 27 | val output: String 28 | ) 29 | 30 | suspend fun process( 31 | vararg command: String, 32 | directory: Path? = null 33 | ): ProcessResult { 34 | return withContext(Dispatchers.IO) { 35 | val process = ProcessBuilder() 36 | .directory(directory?.toFile()) 37 | .command(*command) 38 | .redirectErrorStream(true) 39 | .start() 40 | 41 | val output = async { 42 | process 43 | .inputStream 44 | .bufferedReader() 45 | .lineSequence() 46 | .asFlow() 47 | .map { value -> 48 | yield() 49 | value 50 | } 51 | .toList() 52 | .joinToString(System.lineSeparator()) 53 | } 54 | 55 | try { 56 | val exitCode = runInterruptible { 57 | process.waitFor() 58 | } 59 | val processOutput = output.await() 60 | ProcessResult(exitCode, processOutput) 61 | } catch (e: CancellationException) { 62 | throw e 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/ProgressUpdater.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import kotlinx.coroutines.Job 20 | import kotlinx.coroutines.joinAll 21 | import java.util.concurrent.atomic.AtomicInteger 22 | import java.util.concurrent.locks.ReentrantReadWriteLock 23 | import kotlin.concurrent.read 24 | import kotlin.concurrent.write 25 | import kotlin.math.min 26 | 27 | /** Helps manage updates to a status bar */ 28 | class ProgressUpdater( 29 | val steps: Int, 30 | private val onUpdate: (String, Float) -> Unit, 31 | ) { 32 | private val stepCounter = AtomicInteger(0) 33 | private val jobs = mutableListOf() 34 | 35 | /** Update without advancing progress */ 36 | fun update(message: String) { 37 | sendUpdate(message, stepCounter.get()) 38 | } 39 | 40 | /** 41 | * Update and advance progress 42 | * 43 | * We can advance by more than one step, for example, if a step fails and that prevents the next 3 steps from being 44 | * able to run, we would advance by 4. 45 | */ 46 | fun advance(message: String, steps: Int = 1) { 47 | sendUpdate(message, stepCounter.addAndGet(steps)) 48 | } 49 | 50 | suspend fun waitForJobs() { 51 | jobs.joinAll() 52 | } 53 | 54 | fun skipToEnd(message: String) { 55 | stepCounter.set(steps) 56 | sendUpdate(message, steps) 57 | } 58 | 59 | /** 60 | * Joins all threads and sends the last update 61 | */ 62 | suspend fun finish() { 63 | jobs.joinAll() 64 | val step = stepCounter.get() 65 | if (step < steps) { 66 | Logger.warn("finish() called but progress is not yet finished: step=$step") 67 | } 68 | } 69 | 70 | /** Add a job that needs to be joined before finishing */ 71 | fun addJob(job: Job) { 72 | jobs.add(job) 73 | } 74 | 75 | private fun sendUpdate(message: String, step: Int) { 76 | if (step > steps) { 77 | Logger.warn("Progress already completed while sending: '$message'") 78 | } 79 | Logger.debug("Sending $step/$steps: '$message'") 80 | onUpdate(message, min(steps, step).toFloat() / steps) 81 | } 82 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Regex.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | const val HexDigit = "[0-9a-fA-F]" 20 | 21 | fun MatchResult.getValue(group: String): String { 22 | return groups[group]?.value ?: throw IllegalStateException("Value of $group not found in $value") 23 | } 24 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Settings.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("FunctionName") 18 | 19 | package dev.romainguy.kotlin.explorer 20 | 21 | import androidx.compose.foundation.ExperimentalFoundationApi 22 | import androidx.compose.foundation.layout.* 23 | import androidx.compose.foundation.onClick 24 | import androidx.compose.foundation.text.input.InputTransformation 25 | import androidx.compose.foundation.text.input.TextFieldLineLimits 26 | import androidx.compose.foundation.text.input.TextFieldState 27 | import androidx.compose.foundation.text.input.rememberTextFieldState 28 | import androidx.compose.runtime.* 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.unit.dp 32 | import org.jetbrains.jewel.ui.component.* 33 | import org.jetbrains.jewel.ui.icon.PathIconKey 34 | 35 | 36 | @Composable 37 | fun Settings( 38 | state: ExplorerState, 39 | onSaveRequest: () -> Unit, 40 | onDismissRequest: () -> Unit 41 | ) { 42 | val androidHome = rememberTextFieldState(state.androidHome) 43 | val kotlinHome = rememberTextFieldState(state.kotlinHome) 44 | val kotlinOnlyConsumers = remember { mutableStateOf(state.kotlinOnlyConsumers) } 45 | val compilerFlags = rememberTextFieldState(state.compilerFlags) 46 | val r8rules = rememberTextFieldState(state.r8Rules) 47 | val composeVersion = rememberTextFieldState(state.composeVersion) 48 | val minApi = rememberTextFieldState(state.minApi.toString()) 49 | val indent = rememberTextFieldState(state.indent.toString()) 50 | val lineNumberWidth = rememberTextFieldState(state.lineNumberWidth.toString()) 51 | val decompileHiddenIsa = remember { mutableStateOf(state.decompileHiddenIsa) } 52 | val onSaveClick = { 53 | state.saveState( 54 | androidHome.text.toString(), 55 | kotlinHome.text.toString(), 56 | kotlinOnlyConsumers.value, 57 | compilerFlags.text.toString(), 58 | r8rules.text.toString(), 59 | composeVersion.text.toString(), 60 | minApi.text.toString(), 61 | indent.text.toString(), 62 | lineNumberWidth.text.toString(), 63 | decompileHiddenIsa.value 64 | ) 65 | onSaveRequest() 66 | } 67 | 68 | Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp)) { 69 | val toolPaths = ToolPaths(state.directory, androidHome.text.toString(), kotlinHome.text.toString()) 70 | StringSetting("Android home directory: ", androidHome) { toolPaths.isAndroidHomeValid } 71 | StringSetting("Kotlin home directory: ", kotlinHome) { toolPaths.isKotlinHomeValid } 72 | IntSetting("Decompiled code indent: ", indent, minValue = 2) 73 | IntSetting("Line number column width: ", lineNumberWidth, minValue = 1) 74 | StringSetting("Kotlin compiler flags: ", compilerFlags) 75 | MultiLineStringSetting("R8 rules: ", r8rules) 76 | StringSetting("Compose version: ", composeVersion) {composeVersion.text.isNotEmpty()} 77 | IntSetting("Min API: ", minApi, minValue = 1) 78 | BooleanSetting("Kotlin only consumers", kotlinOnlyConsumers) 79 | BooleanSetting("Decompile hidden instruction sets", decompileHiddenIsa) 80 | Spacer(modifier = Modifier.height(8.dp)) 81 | Buttons(saveEnabled = toolPaths.isValid, onSaveClick, onDismissRequest) 82 | } 83 | } 84 | 85 | @Composable 86 | private fun ColumnScope.Buttons( 87 | saveEnabled: Boolean, 88 | onSaveClick: () -> Unit, 89 | onCancelClick: () -> Unit 90 | ) { 91 | Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.align(Alignment.End)) { 92 | DefaultButton(onClick = onCancelClick) { 93 | Text("Cancel") 94 | } 95 | DefaultButton(enabled = saveEnabled, onClick = onSaveClick) { 96 | Text("Save") 97 | } 98 | } 99 | } 100 | 101 | private fun ExplorerState.saveState( 102 | androidHome: String, 103 | kotlinHome: String, 104 | kotlinOnlyConsumers: Boolean, 105 | compilerFlags: String, 106 | r8Rules: String, 107 | composeVersion: String, 108 | minApi: String, 109 | indent: String, 110 | lineNumberWidth: String, 111 | decompileHiddenIsa: Boolean, 112 | ) { 113 | this.androidHome = androidHome 114 | this.kotlinHome = kotlinHome 115 | this.kotlinOnlyConsumers = kotlinOnlyConsumers 116 | this.compilerFlags = compilerFlags 117 | this.r8Rules = r8Rules 118 | this.composeVersion = composeVersion 119 | this.minApi = minApi.toIntOrNull() ?: 21 120 | this.indent = indent.toIntOrNull() ?: 4 121 | this.lineNumberWidth = lineNumberWidth.toIntOrNull() ?: 4 122 | this.decompileHiddenIsa = decompileHiddenIsa 123 | this.reloadToolPathsFromSettings() 124 | } 125 | 126 | @Composable 127 | private fun StringSetting(title: String, state: TextFieldState, isValid: () -> Boolean = { true }) { 128 | SettingRow(title, state, isValid) 129 | } 130 | 131 | @Composable 132 | private fun ColumnScope.MultiLineStringSetting(title: String, state: TextFieldState) { 133 | Row(Modifier.weight(1.0f)) { 134 | Text( 135 | title, 136 | modifier = Modifier 137 | .alignByBaseline() 138 | .defaultMinSize(minWidth = 200.dp), 139 | ) 140 | TextArea( 141 | state, 142 | modifier = Modifier 143 | .weight(1.0f) 144 | .fillMaxHeight(), 145 | lineLimits = TextFieldLineLimits.MultiLine(10, 10) 146 | ) 147 | } 148 | } 149 | 150 | @Composable 151 | private fun IntSetting(title: String, state: TextFieldState, minValue: Int) { 152 | val isValid by derivedStateOf { 153 | (state.text.toString().toIntOrNull() ?: Int.MIN_VALUE) >= minValue 154 | } 155 | 156 | SettingRow(title, state, isValid = { isValid }, { 157 | val changed = this.toString() 158 | if (changed.isNotEmpty() && changed.toIntOrNull() == null) { 159 | revertAllChanges() 160 | } 161 | }) 162 | } 163 | 164 | @OptIn(ExperimentalFoundationApi::class) 165 | @Composable 166 | private fun BooleanSetting(@Suppress("SameParameterValue") title: String, state: MutableState) { 167 | Row { 168 | Checkbox(state.value, onCheckedChange = { state.value = it }) 169 | Text( 170 | title, 171 | modifier = Modifier 172 | .align(Alignment.CenterVertically) 173 | .padding(start = 2.dp) 174 | .onClick { 175 | state.value = !state.value 176 | } 177 | ) 178 | } 179 | } 180 | 181 | @Composable 182 | private fun SettingRow( 183 | title: String, 184 | state: TextFieldState, 185 | isValid: () -> Boolean, 186 | inputTransformation: InputTransformation? = null 187 | ) { 188 | Row(Modifier.fillMaxWidth()) { 189 | Text( 190 | title, 191 | modifier = Modifier 192 | .alignByBaseline() 193 | .defaultMinSize(minWidth = 200.dp), 194 | ) 195 | TextField( 196 | state, 197 | modifier = Modifier 198 | .alignByBaseline() 199 | .defaultMinSize(minWidth = 360.dp) 200 | .weight(1.0f), 201 | inputTransformation = inputTransformation, 202 | trailingIcon = { if (isValid()) ValidIcon() else ErrorIcon() } 203 | ) 204 | } 205 | } 206 | 207 | @Composable 208 | private fun ErrorIcon() { 209 | Icon( 210 | key = PathIconKey( 211 | "icons/error.svg", 212 | iconClass = ExplorerState::class.java 213 | ), contentDescription = "Error", 214 | tint = IconErrorColor, 215 | hints = arrayOf() 216 | ) 217 | } 218 | 219 | @Composable 220 | private fun ValidIcon() { 221 | Icon( 222 | key = PathIconKey( 223 | "icons/done.svg", 224 | iconClass = ExplorerState::class.java 225 | ), contentDescription = "Valid", 226 | tint = IconValidColor, 227 | hints = arrayOf() 228 | ) 229 | } 230 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/SourceTextArea.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import dev.romainguy.kotlin.explorer.code.CodeTextArea 20 | import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea 21 | import java.awt.event.MouseAdapter 22 | import java.awt.event.MouseEvent 23 | 24 | class SourceTextArea( 25 | var isSyncLinesEnabled: Boolean 26 | ) : SyntaxTextArea() { 27 | private val codeTextAreas = mutableListOf() 28 | 29 | init { 30 | addMouseListener(object : MouseAdapter() { 31 | override fun mouseClicked(event: MouseEvent) { 32 | if (isSyncLinesEnabled) { 33 | codeTextAreas.forEach { 34 | it.gotoSourceLine(getLineOfOffset(viewToModel2D(event.point))) 35 | } 36 | } 37 | } 38 | }) 39 | } 40 | 41 | fun addCodeTextAreas(vararg codeTextAreas: CodeTextArea) { 42 | this.codeTextAreas.addAll(codeTextAreas) 43 | } 44 | 45 | fun gotoLine(src: CodeTextArea, line: Int) { 46 | caretPosition = getLineStartOffset(line.coerceIn(0 until lineCount)) 47 | centerCaretInView() 48 | // Sync other `CodeTextArea` to same line as the `src` sent 49 | codeTextAreas.filter { it !== src }.forEach { it.gotoSourceLine(line) } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Splitter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("FunctionName", "OPT_IN_USAGE", "KotlinRedundantDiagnosticSuppress") 18 | @file:OptIn(ExperimentalSplitPaneApi::class) 19 | 20 | package dev.romainguy.kotlin.explorer 21 | 22 | import androidx.compose.foundation.layout.* 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.input.pointer.PointerIcon 26 | import androidx.compose.ui.input.pointer.pointerHoverIcon 27 | import androidx.compose.ui.unit.dp 28 | import org.jetbrains.compose.splitpane.* 29 | import java.awt.Cursor 30 | 31 | private fun Modifier.cursorForHorizontalResize(): Modifier = 32 | pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) 33 | 34 | private fun Modifier.cursorForVerticalResize(): Modifier = 35 | pointerHoverIcon(PointerIcon(Cursor(Cursor.N_RESIZE_CURSOR))) 36 | 37 | fun SplitterScope.HorizontalSplitter() { 38 | visiblePart { 39 | Box( 40 | Modifier 41 | .width(5.dp) 42 | .fillMaxHeight() 43 | ) 44 | } 45 | handle { 46 | Box( 47 | Modifier 48 | .markAsHandle() 49 | .cursorForHorizontalResize() 50 | .width(5.dp) 51 | .fillMaxHeight() 52 | ) 53 | } 54 | } 55 | 56 | fun SplitterScope.VerticalSplitter() { 57 | visiblePart { 58 | Box( 59 | Modifier 60 | .height(5.dp) 61 | .fillMaxWidth() 62 | ) 63 | } 64 | handle { 65 | Box( 66 | Modifier 67 | .markAsHandle() 68 | .cursorForVerticalResize() 69 | .height(5.dp) 70 | .fillMaxWidth() 71 | ) 72 | } 73 | } 74 | 75 | @Composable 76 | fun MultiSplitter(modifier: Modifier = Modifier, panels: List<@Composable () -> Unit>) { 77 | val size = panels.size 78 | if (size == 1) { 79 | panels[0]() 80 | } else { 81 | HorizontalSplitPane( 82 | modifier = modifier, 83 | splitPaneState = rememberSplitPaneState(initialPositionPercentage = 1.0f / size) 84 | ) { 85 | first { panels[0]() } 86 | second { MultiSplitter(modifier = modifier, panels.drop(1)) } 87 | splitter { HorizontalSplitter() } 88 | } 89 | } 90 | } 91 | 92 | @Composable 93 | fun VerticalOptionalPanel( 94 | modifier: Modifier = Modifier, 95 | showOptionalPanel: Boolean = false, 96 | optionalPanel: @Composable () -> Unit, 97 | content: @Composable () -> Unit 98 | ) { 99 | if (showOptionalPanel) { 100 | VerticalSplitPane( 101 | modifier = modifier, 102 | splitPaneState = rememberSplitPaneState(initialPositionPercentage = 4f / 5f) 103 | ) { 104 | first { content() } 105 | second { optionalPanel() } 106 | splitter { VerticalSplitter() } 107 | } 108 | } else { 109 | content() 110 | } 111 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/State.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import androidx.compose.runtime.* 20 | import androidx.compose.ui.window.WindowPlacement 21 | import androidx.compose.ui.window.WindowPlacement.Floating 22 | import java.nio.file.Files 23 | import java.nio.file.Path 24 | import java.nio.file.Paths 25 | import kotlin.io.path.exists 26 | import kotlin.io.path.readLines 27 | 28 | private const val AndroidHome = "ANDROID_HOME" 29 | private const val KotlinHome = "KOTLIN_HOME" 30 | private const val Optimize = "OPTIMIZE" 31 | private const val KeepEverything = "KEEP_EVERYTHING" 32 | private const val KotlinOnlyConsumers = "KOTLIN_ONLY_CONSUMERS" 33 | private const val CompilerFlags = "COMPILER_FLAGS" 34 | private const val R8Rules = "R8_RULES" 35 | private const val ComposeVersion = "COMPOSE_VERSION" 36 | private const val MinApi = "MIN_API" 37 | private const val AutoBuildOnStartup = "AUTO_BUILD_ON_STARTUP" 38 | private const val Presentation = "PRESENTATION" 39 | private const val ShowLineNumbers = "SHOW_LINE_NUMBERS" 40 | private const val ShowByteCode = "SHOW_BYTE_CODE" 41 | private const val ShowDex = "SHOW_DEX" 42 | private const val ShowOat = "SHOW_OAT" 43 | private const val SyncLines = "SYNC_LINES" 44 | private const val Indent = "INDENT" 45 | private const val DecompileHiddenIsa = "DECOMPILE_HIDDEN_ISA" 46 | private const val LineNumberWidth = "LINE_NUMBER_WIDTH" 47 | private const val WindowPosX = "WINDOW_X" 48 | private const val WindowPosY = "WINDOW_Y" 49 | private const val WindowWidth = "WINDOW_WIDTH" 50 | private const val WindowHeight = "WINDOW_HEIGHT" 51 | private const val Placement = "WINDOW_PLACEMENT" 52 | 53 | @Stable 54 | class ExplorerState { 55 | val directory: Path = settingsPath() 56 | private val file: Path = directory.resolve("settings") 57 | private val entries: MutableMap = readSettings(file) 58 | 59 | var androidHome by StringState(AndroidHome, System.getenv("ANDROID_HOME") ?: System.getProperty("user.home")) 60 | var kotlinHome by StringState(KotlinHome, System.getenv("KOTLIN_HOME") ?: System.getProperty("user.home")) 61 | var toolPaths by mutableStateOf(createToolPaths()) 62 | var optimize by BooleanState(Optimize, true) 63 | var keepEverything by BooleanState(KeepEverything, true) 64 | var kotlinOnlyConsumers by BooleanState(KotlinOnlyConsumers, true) 65 | var compilerFlags by StringState(CompilerFlags, "") 66 | var r8Rules by StringState(R8Rules, "") 67 | var composeVersion by StringState(ComposeVersion, "1.8.1") 68 | var minApi by IntState(MinApi, 21) 69 | var autoBuildOnStartup by BooleanState(AutoBuildOnStartup, false) 70 | var presentationMode by BooleanState(Presentation, false) 71 | var showLineNumbers by BooleanState(ShowLineNumbers, false) 72 | var showByteCode by BooleanState(ShowByteCode, false) 73 | var showDex by BooleanState(ShowDex, true) 74 | var showOat by BooleanState(ShowOat, true) 75 | var showLogsAndDocumentation by mutableStateOf(false) 76 | var syncLines by BooleanState(SyncLines, true) 77 | var lineNumberWidth by IntState(LineNumberWidth, 4) 78 | var indent by IntState(Indent, 4) 79 | var decompileHiddenIsa by BooleanState(DecompileHiddenIsa, true) 80 | var sourceCode: String = readSourceCode(toolPaths) 81 | var windowWidth by IntState(WindowWidth, 1900) 82 | var windowHeight by IntState(WindowHeight, 1600) 83 | var windowPosX by IntState(WindowPosX, -1) 84 | var windowPosY by IntState(WindowPosY, -1) 85 | var windowPlacement by SettingsState(Placement, Floating) { WindowPlacement.valueOf(this) } 86 | 87 | fun reloadToolPathsFromSettings() { 88 | toolPaths = createToolPaths() 89 | } 90 | 91 | private fun createToolPaths() = ToolPaths(directory, Path.of(androidHome), Path.of(kotlinHome)) 92 | 93 | private inner class BooleanState(key: String, initialValue: Boolean) : 94 | SettingsState(key, initialValue, { toBoolean() }) 95 | 96 | private inner class IntState(key: String, initialValue: Int) : 97 | SettingsState(key, initialValue, { toInt() }) 98 | 99 | private inner class StringState(key: String, initialValue: String) : 100 | SettingsState(key, initialValue, { this }) 101 | 102 | private open inner class SettingsState(private val key: String, initialValue: T, parse: String.() -> T) : 103 | MutableState { 104 | private val state = mutableStateOf(entries[key]?.parse() ?: initialValue) 105 | override var value: T 106 | get() = state.value 107 | set(value) { 108 | entries[key] = value.toString() 109 | state.value = value 110 | } 111 | 112 | override fun component1() = state.component1() 113 | 114 | override fun component2() = state.component2() 115 | } 116 | 117 | fun writeSourceCodeState() { 118 | Files.writeString(toolPaths.sourceFile, sourceCode) 119 | } 120 | 121 | fun writeState() { 122 | writeSourceCodeState() 123 | Files.writeString( 124 | file, 125 | entries.map { (key, value) -> "$key=${value.replace("\n", "\\\n")}" }.joinToString("\n") 126 | ) 127 | } 128 | } 129 | 130 | private fun settingsPath() = Paths.get(System.getProperty("user.home"), ".kotlin-explorer").apply { 131 | if (!exists()) Files.createDirectory(this) 132 | } 133 | 134 | private fun readSettings(file: Path): MutableMap { 135 | val settings = mutableMapOf() 136 | if (!file.exists()) return settings 137 | 138 | val lines = file.readLines() 139 | var i = 0 140 | while (i < lines.size) { 141 | val line = lines[i] 142 | val index = line.indexOf('=') 143 | if (index != -1) { 144 | var value = line.substring(index + 1) 145 | if (value.endsWith('\\')) { 146 | value = value.dropLast(1) + '\n' 147 | do { 148 | i++ 149 | if (i >= lines.size) break 150 | value += lines[i].dropLast(1) 151 | val continuation = lines[i].endsWith('\\') 152 | if (continuation) value += '\n' 153 | } while (continuation) 154 | } 155 | settings[line.substring(0, index)] = value 156 | } 157 | i++ 158 | } 159 | 160 | return settings 161 | } 162 | 163 | private fun readSourceCode(toolPaths: ToolPaths) = if (toolPaths.sourceFile.exists()) { 164 | Files.readString(toolPaths.sourceFile) 165 | } else { 166 | """ 167 | // NOTE: If Build > Keep Everything is *not* checked, used the @Keep 168 | // annotation to keep the classes/methods/etc. you want to disassemble 169 | fun square(a: Int): Int { 170 | return a * a 171 | } 172 | 173 | """.trimIndent() 174 | } 175 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/String.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | fun Iterator.consumeUntil(prefix: String): Boolean { 20 | while (hasNext()) { 21 | val line = next() 22 | if (line.trim().startsWith(prefix)) return true 23 | } 24 | return false 25 | } 26 | 27 | fun Iterator.consumeUntil(regex: Regex): MatchResult? { 28 | while (hasNext()) { 29 | val line = next() 30 | val match = regex.matchEntire(line) 31 | if (match != null) { 32 | return match 33 | } 34 | } 35 | return null 36 | } 37 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Swing.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("FunctionName") 18 | 19 | package dev.romainguy.kotlin.explorer 20 | 21 | import java.awt.Point 22 | import javax.swing.JTextArea 23 | import javax.swing.JViewport 24 | 25 | fun JTextArea.centerCaretInView() { 26 | val viewport = parent as? JViewport ?: return 27 | val linePos = modelToView2D(caretPosition).bounds.centerY.toInt() 28 | viewport.viewPosition = Point(0, maxOf(0, linePos - viewport.height / 2)) 29 | } 30 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/SyntaxTextArea.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea 20 | import javax.swing.JScrollPane 21 | import javax.swing.JViewport 22 | 23 | private const val FontSizeEditingMode = 12.0f 24 | private const val FontSizePresentationMode = 20.0f 25 | 26 | open class SyntaxTextArea : RSyntaxTextArea() { 27 | var presentationMode = false 28 | set(value) { 29 | if (field != value) { 30 | field = value 31 | updateFontSize() 32 | } 33 | } 34 | 35 | private fun updateFontSize() { 36 | val scheme = syntaxScheme 37 | 38 | val increaseRatio = if (presentationMode) { 39 | FontSizePresentationMode / FontSizeEditingMode 40 | } else { 41 | FontSizeEditingMode / FontSizePresentationMode 42 | } 43 | 44 | val count = scheme.styleCount 45 | for (i in 0 until count) { 46 | val ss = scheme.getStyle(i) 47 | if (ss != null) { 48 | val font = ss.font 49 | if (font != null) { 50 | val oldSize: Float = font.size2D 51 | val newSize: Float = oldSize * increaseRatio 52 | ss.font = font.deriveFont(newSize) 53 | } 54 | } 55 | } 56 | 57 | font = font.deriveFont(if (presentationMode) FontSizePresentationMode else FontSizeEditingMode) 58 | 59 | syntaxScheme = scheme 60 | var parent = parent 61 | if (parent is JViewport) { 62 | parent = parent.parent 63 | if (parent is JScrollPane) { 64 | parent.repaint() 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Theme.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer 18 | 19 | import androidx.compose.ui.graphics.Color 20 | import org.jetbrains.skiko.SystemTheme 21 | 22 | val IconErrorColor = Color(0xffee4056) 23 | val IconValidColor = Color(0xff3369d6) 24 | val ErrorColor = Color(0xffa04646) 25 | val ProgressColor = Color(0xff3369d6) 26 | val ProgressTrackColor = Color(0xffc4c4c4) 27 | 28 | enum class KotlinExplorerTheme { 29 | Dark, Light, System; 30 | 31 | // TODO: Using currentSystemTheme leads to an UnsatisfiedLinkError with the JetBrains Runtime 32 | fun isDark() = (if (this == System) fromSystemTheme(/* currentSystemTheme */ SystemTheme.LIGHT) else this) == Dark 33 | 34 | companion object { 35 | fun fromSystemTheme(systemTheme: SystemTheme) = if (systemTheme == SystemTheme.LIGHT) Light else Dark 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/build/ByteCodeDecompiler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.build 18 | 19 | import dev.romainguy.kotlin.explorer.process 20 | import java.nio.file.Path 21 | import kotlin.io.path.ExperimentalPathApi 22 | import kotlin.io.path.extension 23 | import kotlin.io.path.pathString 24 | import kotlin.io.path.walk 25 | 26 | class ByteCodeDecompiler { 27 | suspend fun decompile(directory: Path) = process(*buildJavapCommand(directory), directory = directory) 28 | 29 | @OptIn(ExperimentalPathApi::class) 30 | private fun buildJavapCommand(directory: Path): Array { 31 | val command = mutableListOf("javap", "-p", "-l", "-c") 32 | val classFiles = directory.walk() 33 | .filter { path -> path.extension == "class" } 34 | .map { path -> directory.relativize(path).pathString } 35 | .sorted() 36 | command.addAll(classFiles) 37 | return command.toTypedArray() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/build/DexCompiler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.build 18 | 19 | import dev.romainguy.kotlin.explorer.ProcessResult 20 | import dev.romainguy.kotlin.explorer.ToolPaths 21 | import dev.romainguy.kotlin.explorer.process 22 | import java.nio.file.Files 23 | import java.nio.file.Path 24 | import kotlin.io.path.* 25 | 26 | class DexCompiler(private val toolPaths: ToolPaths, private val outputDirectory: Path, private val r8rules: String, private val minApi: Int) { 27 | suspend fun buildDex(optimize: Boolean, keepEverything: Boolean): ProcessResult { 28 | return process(*buildDexCommand(optimize, keepEverything), directory = outputDirectory) 29 | } 30 | 31 | suspend fun dumpDex() = process( 32 | toolPaths.dexdump.toString(), 33 | "-d", 34 | "classes.dex", 35 | directory = outputDirectory 36 | ) 37 | 38 | @OptIn(ExperimentalPathApi::class) 39 | private fun buildDexCommand(optimize: Boolean, keepEverything: Boolean): Array { 40 | writeR8Rules(keepEverything) 41 | 42 | return buildList { 43 | add("java") 44 | add("-classpath") 45 | add(toolPaths.d8.toString()) 46 | add(if (optimize) "com.android.tools.r8.R8" else "com.android.tools.r8.D8") 47 | add("--min-api") 48 | add(minApi.toString()) 49 | if (optimize) { 50 | add("--pg-conf") 51 | add("rules.txt") 52 | } 53 | add("--output") 54 | add(".") 55 | add("--lib") 56 | add(toolPaths.platform.toString()) 57 | if (!optimize) { 58 | toolPaths.kotlinLibs.forEach { path -> 59 | add("--lib") 60 | add(path.pathString) 61 | } 62 | } 63 | outputDirectory.walk() 64 | .map { path -> outputDirectory.relativize(path) } 65 | .filter { path -> path.extension == "class" && path.first().name != "META-INF" } 66 | .forEach { path -> add(path.pathString) } 67 | 68 | if (optimize) { 69 | addAll(toolPaths.kotlinLibs.map { it.toString() }) 70 | } 71 | 72 | }.toTypedArray() 73 | } 74 | 75 | private fun writeR8Rules(keepEverything: Boolean) { 76 | // Match $ANDROID_HOME/tools/proguard/proguard-android-optimize.txt 77 | Files.writeString( 78 | outputDirectory.resolve("rules.txt"), 79 | buildString { 80 | append( 81 | """ 82 | -optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* 83 | -optimizationpasses 5 84 | -allowaccessmodification 85 | -dontpreverify 86 | -dontobfuscate 87 | """.trimIndent() 88 | ) 89 | if (keepEverything) { 90 | append( 91 | """ 92 | -keep,allowoptimization class !kotlin.**,!kotlinx.** { 93 | ; 94 | } 95 | """.trimIndent() 96 | ) 97 | } else { 98 | append( 99 | """ 100 | -keep,allowobfuscation @interface Keep 101 | -keep @Keep class * {*;} 102 | -keepclasseswithmembers class * { 103 | @Keep ; 104 | } 105 | -keepclasseswithmembers class * { 106 | @Keep ; 107 | } 108 | -keepclasseswithmembers class * { 109 | @Keep (...); 110 | } 111 | """.trimIndent() 112 | ) 113 | } 114 | append(r8rules) 115 | } 116 | ) 117 | } 118 | } 119 | 120 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/build/KolinCompiler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.build 18 | 19 | import dev.romainguy.kotlin.explorer.DependencyCache 20 | import dev.romainguy.kotlin.explorer.ProcessResult 21 | import dev.romainguy.kotlin.explorer.ToolPaths 22 | import dev.romainguy.kotlin.explorer.process 23 | import java.io.File 24 | import java.nio.file.Path 25 | import kotlin.io.path.useLines 26 | 27 | private val BuiltInFiles = listOf( 28 | "Keep.kt", 29 | "NeverInline.kt" 30 | ) 31 | 32 | class KotlinCompiler( 33 | private val toolPaths: ToolPaths, 34 | settingsDirectory: Path, 35 | private val outputDirectory: Path, 36 | ) { 37 | private val dependencyCache = DependencyCache(settingsDirectory.resolve("dependency-cache")) 38 | 39 | suspend fun compile( 40 | kotlinOnlyConsumers: Boolean, 41 | compilerFlags: String, 42 | composeVersion: String, 43 | source: Path 44 | ): ProcessResult { 45 | val sb = StringBuilder() 46 | val result = process( 47 | *buildCompileCommand( 48 | kotlinOnlyConsumers, 49 | compilerFlags, 50 | composeVersion, 51 | source 52 | ) { sb.appendLine(it) }, directory = outputDirectory 53 | ) 54 | return ProcessResult(result.exitCode, "$sb\n${result.output}") 55 | } 56 | 57 | private suspend fun buildCompileCommand( 58 | kotlinOnlyConsumers: Boolean, 59 | compilerFlags: String, 60 | composeVersion: String, 61 | file: Path, 62 | onOutput: (String) -> Unit 63 | ): Array { 64 | val isCompose = file.isCompose() 65 | val classpath = buildList { 66 | addAll(toolPaths.kotlinLibs) 67 | add(toolPaths.platform) 68 | if (isCompose) { 69 | add( 70 | dependencyCache.getDependency( 71 | "androidx.compose.runtime", 72 | "runtime-android", 73 | composeVersion, 74 | onOutput, 75 | ) 76 | ) 77 | } 78 | }.joinToString((File.pathSeparator)) { it.toString() } 79 | 80 | val command = buildList { 81 | add(toolPaths.kotlinc.toString()) 82 | add("-Xmulti-platform") 83 | add("-classpath") 84 | add(classpath) 85 | if (kotlinOnlyConsumers) { 86 | this += "-Xno-param-assertions" 87 | this += "-Xno-call-assertions" 88 | this += "-Xno-receiver-assertions" 89 | } 90 | if (compilerFlags.isNotEmpty() || compilerFlags.isNotBlank()) { 91 | // TODO: Do something smarter in case a flag looks like -foo="something with space" 92 | addAll(compilerFlags.split(' ')) 93 | } 94 | if (isCompose) { 95 | val composePlugin = dependencyCache.getDependency( 96 | "org.jetbrains.kotlin", 97 | "kotlin-compose-compiler-plugin", 98 | getKotlinVersion(), 99 | onOutput, 100 | ) 101 | add("-Xplugin=$composePlugin") 102 | } 103 | // Source code to compile 104 | this += file.toString() 105 | for (fileName in BuiltInFiles) { 106 | this += file.parent.resolve(fileName).toString() 107 | } 108 | } 109 | 110 | return command.toTypedArray() 111 | } 112 | 113 | private suspend fun getKotlinVersion(): String { 114 | val command = mutableListOf( 115 | toolPaths.kotlinc.toString(), 116 | "-version", 117 | ).toTypedArray() 118 | return process(*command).output.substringAfter("kotlinc-jvm ").substringBefore(" ") 119 | } 120 | } 121 | 122 | private fun Path.isCompose(): Boolean { 123 | return useLines { lines -> 124 | lines.filter { it.trim().startsWith("import") } 125 | .any { it.split(" ").last() == "androidx.compose.runtime.Composable" } 126 | } 127 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/bytecode/ByteCodeParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.bytecode 18 | 19 | import androidx.collection.IntIntMap 20 | import androidx.collection.mutableIntIntMapOf 21 | import dev.romainguy.kotlin.explorer.PeekingIterator 22 | import dev.romainguy.kotlin.explorer.code.* 23 | import dev.romainguy.kotlin.explorer.code.CodeContent.Error 24 | import dev.romainguy.kotlin.explorer.code.CodeContent.Success 25 | import dev.romainguy.kotlin.explorer.consumeUntil 26 | import dev.romainguy.kotlin.explorer.getValue 27 | import dev.romainguy.kotlin.explorer.oat.codeToOpAndOperands 28 | 29 | /* 30 | * Example: 31 | * ``` 32 | * public final class KotlinExplorerKt { 33 | * ``` 34 | */ 35 | private val ClassRegex = Regex("^(?
.* class [_a-zA-Z][_.$\\w]+).*\\{$") 36 | 37 | /** 38 | * Example: 39 | * ``` 40 | * testData.InnerClassKt$main$1(); 41 | * ``` 42 | */ 43 | private val MethodRegex = Regex("^ {2}(?
.*\\));$") 44 | 45 | /** 46 | * Example: 47 | * 48 | * ``` 49 | * 10: ifne 16 50 | * ``` 51 | */ 52 | 53 | private val InstructionRegex = Regex("^(?
\\d+): +(?.*)$") 54 | 55 | /** 56 | * Examples: 57 | * 58 | * ``` 59 | * 4: if_icmpge 31 60 | * 10: ifne 16 61 | * 13: goto 25 62 | * ``` 63 | */ 64 | private val JumpRegex = Regex("^(goto|if[_a-z]*) +(?
\\d+)$") 65 | 66 | private val CommentRegex = Regex("\\s+//") 67 | 68 | class ByteCodeParser { 69 | fun parse(text: String): CodeContent { 70 | return try { 71 | val lines = PeekingIterator(text.lineSequence().iterator()) 72 | 73 | val classes = buildList { 74 | while (lines.hasNext()) { 75 | val match = lines.consumeUntil(ClassRegex) ?: break 76 | val clazz = lines.readClass(match.getValue("header")) 77 | add(clazz) 78 | } 79 | } 80 | Success(classes) 81 | } catch (e: Exception) { 82 | Error(e) 83 | } 84 | } 85 | } 86 | 87 | private fun PeekingIterator.readClass(classHeader: String): Class { 88 | val methods = buildList { 89 | while (hasNext()) { 90 | val line = peek() 91 | when { 92 | line == "}" -> break 93 | MethodRegex.matches(line) -> add(readMethod()) 94 | else -> next() 95 | } 96 | } 97 | if (next() != "}") { 98 | throw IllegalStateException("Expected '}' but got '${peek()}'") 99 | } 100 | } 101 | return Class(classHeader, methods, false) 102 | } 103 | 104 | 105 | private fun PeekingIterator.readMethod(): Method { 106 | val match = MethodRegex.matchEntire(next()) 107 | ?: throw IllegalStateException("Expected method but got '${peek()}'") 108 | val header = match.getValue("header") 109 | // A method can have no code 110 | if (next().trim() != "Code:") return Method(header, InstructionSet(ISA.ByteCode, emptyList())) 111 | 112 | val instructions = readInstructions() 113 | val lineNumbers = readLineNumbers() 114 | 115 | return Method(header, InstructionSet(ISA.ByteCode, instructions.withLineNumbers(lineNumbers))) 116 | } 117 | 118 | 119 | private fun PeekingIterator.readInstructions(): List { 120 | return buildList { 121 | while (hasNext()) { 122 | val line = peek().trim() 123 | val match = InstructionRegex.matchEntire(line) ?: break 124 | next() 125 | val address = match.getValue("address") 126 | val code = match.getValue("code").replace(CommentRegex, " //") 127 | val jumpAddress = JumpRegex.matchEntire(code)?.getValue("address")?.toInt() ?: -1 128 | val (op, operands) = codeToOpAndOperands(code) 129 | add(Instruction(address.toInt(), address, op, operands, jumpAddress)) 130 | } 131 | } 132 | } 133 | 134 | private fun PeekingIterator.readLineNumbers(): IntIntMap { 135 | val map = mutableIntIntMapOf() 136 | val found = skipToLineNumberTable() 137 | if (!found) { 138 | return map 139 | } 140 | next() 141 | while (hasNext()) { 142 | val line = peek().trim() 143 | if (!line.startsWith("line")) { 144 | break 145 | } 146 | next() 147 | val (lineNumber, address) = line.substringAfter(' ').split(": ", limit = 2) 148 | map.put(address.toInt(), lineNumber.toInt()) 149 | } 150 | return map 151 | } 152 | 153 | private fun PeekingIterator.skipToLineNumberTable(): Boolean { 154 | while (hasNext()) { 155 | val line = peek().trim() 156 | when (line) { 157 | "LineNumberTable:" -> return true 158 | "", "}" -> return false 159 | } 160 | next() 161 | } 162 | return false 163 | } 164 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/Code.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | import androidx.collection.IntIntMap 20 | import androidx.collection.IntObjectMap 21 | import androidx.collection.mutableIntObjectMapOf 22 | 23 | /** 24 | * A data model representing disassembled code 25 | * 26 | * Given a list [Class]'s constructs a mode that provides: 27 | * - Disassembled text with optional line number annotations 28 | * - Jump information for branch instructions 29 | */ 30 | class Code( 31 | val isa: ISA, 32 | val text: String, 33 | val instructions: IntObjectMap, 34 | private val jumps: IntIntMap, 35 | private val sourceToCodeLine: IntIntMap, 36 | private val codeToSourceToLine: IntIntMap, 37 | ) { 38 | fun getJumpTargetOfLine(line: Int) = jumps.getOrDefault(line, -1) 39 | 40 | fun getCodeLine(sourceLine: Int) = sourceToCodeLine.getOrDefault(sourceLine, -1) 41 | 42 | fun getSourceLine(codeLine: Int) = codeToSourceToLine.getOrDefault(codeLine, -1) 43 | 44 | companion object { 45 | fun fromClasses(classes: List, codeStyle: CodeStyle = CodeStyle()): Code { 46 | return buildCode(codeStyle) { 47 | val indexedMethods = buildIndexedMethods(classes) 48 | classes.forEachIndexed { classIndex, clazz -> 49 | if (clazz.builtIn) return@forEachIndexed 50 | startClass(clazz) 51 | val notLastClass = classIndex < classes.size - 1 52 | clazz.methods.forEachIndexed { methodIndex, method -> 53 | writeMethod(method, indexedMethods) 54 | if (methodIndex < clazz.methods.size - 1 || notLastClass) writeLine("") 55 | } 56 | } 57 | }.build() 58 | } 59 | 60 | private fun buildIndexedMethods(classes: List): IntObjectMap { 61 | val map = mutableIntObjectMapOf() 62 | classes.forEach { clazz -> 63 | clazz.methods.forEach { method -> 64 | if (method.index != -1) { 65 | map[method.index] = method 66 | } 67 | } 68 | } 69 | return map 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/CodeBuilder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | import androidx.collection.IntIntPair 20 | import androidx.collection.IntObjectMap 21 | import androidx.collection.mutableIntIntMapOf 22 | import androidx.collection.mutableIntObjectMapOf 23 | import kotlin.math.max 24 | 25 | fun buildCode(codeStyle: CodeStyle = CodeStyle(), builderAction: CodeBuilder.() -> Unit): CodeBuilder { 26 | return CodeBuilder(codeStyle).apply(builderAction) 27 | } 28 | 29 | /** 30 | * Builds a [Code] model 31 | * 32 | * This class maintains a state allowing it to build the `jump` table `line-number` list 33 | * that will be used by the UI to display jump markers and line number annotations. 34 | */ 35 | class CodeBuilder(private val codeStyle: CodeStyle) { 36 | private var line = 0 37 | private val sb = StringBuilder() 38 | private val jumps = mutableIntIntMapOf() 39 | private val sourceToCodeLine = mutableIntIntMapOf() 40 | private val codeToSourceToLine = mutableIntIntMapOf() 41 | private var isa = ISA.Aarch64 42 | private val instructions = mutableIntObjectMapOf() 43 | 44 | // These 3 fields collect method scope data. They are reset when a method is added 45 | private val methodAddresses = mutableIntIntMapOf() 46 | private val methodJumps = mutableListOf() 47 | private var lastMethodLineNumber: Int = -1 48 | 49 | private fun alignOpCodes(isa: ISA) = when (isa) { 50 | ISA.ByteCode -> true 51 | ISA.X86_64 -> true 52 | ISA.Aarch64 -> true 53 | else -> false 54 | } 55 | 56 | fun startClass(clazz: Class) { 57 | writeLine(clazz.header) 58 | } 59 | 60 | fun writeMethod(method: Method, indexedMethods: IntObjectMap) { 61 | startMethod(method) 62 | 63 | val instructionSet = method.instructionSet 64 | // TODO: We should do this only once 65 | isa = instructionSet.isa 66 | val opCodeLength = if (alignOpCodes(instructionSet.isa)) opCodeLength(instructionSet) else -1 67 | 68 | instructionSet.instructions.forEach { instruction -> 69 | writeInstruction(instructionSet, instruction, indexedMethods, opCodeLength) 70 | } 71 | 72 | endMethod() 73 | } 74 | 75 | private fun opCodeLength(instructionSet: InstructionSet): Int { 76 | var maxLength = 0 77 | instructionSet.instructions.forEach { instruction -> 78 | val opCode = instruction.op 79 | maxLength = max(maxLength, opCode.length) 80 | } 81 | return maxLength 82 | } 83 | 84 | private fun startMethod(method: Method) { 85 | sb.append(" ".repeat(codeStyle.indent)) 86 | writeLine(method.header) 87 | 88 | val indent = " ".repeat(codeStyle.indent) 89 | 90 | sb.append(indent) 91 | val codeSize = method.codeSize 92 | val instructionCount = method.instructionSet.instructions.size 93 | writeLine("-- $instructionCount instruction${if (instructionCount > 1) "s" else ""}${if (codeSize >= 0) " ($codeSize bytes)" else ""}") 94 | 95 | val (pre, post) = countBranches(method.instructionSet) 96 | val branches = pre + post 97 | if (branches > 0) { 98 | sb.append(indent) 99 | writeLine("-- $branches branch${if (branches > 1) "es" else ""} ($pre + $post)") 100 | } 101 | } 102 | 103 | private fun countBranches(instructionSet: InstructionSet): IntIntPair { 104 | var preReturnCount = 0 105 | var postReturnCount = 0 106 | var returnSeen = false 107 | 108 | val branchInstructions = instructionSet.isa.branchInstructions 109 | val returnInstructions = instructionSet.isa.returnInstructions 110 | 111 | instructionSet.instructions.forEach { instruction -> 112 | val opCode = instruction.op 113 | if (returnInstructions.contains(opCode)) { 114 | returnSeen = true 115 | } else { 116 | if (branchInstructions.contains(opCode)) { 117 | if (returnSeen) { 118 | postReturnCount++ 119 | } else { 120 | preReturnCount++ 121 | } 122 | } 123 | } 124 | } 125 | 126 | return IntIntPair(preReturnCount, postReturnCount) 127 | } 128 | 129 | private fun endMethod() { 130 | methodJumps.forEach { (line, address) -> 131 | val targetLine = methodAddresses.getOrDefault(address, -1) 132 | if (targetLine == -1) return@forEach 133 | jumps[line] = targetLine 134 | } 135 | methodAddresses.clear() 136 | methodJumps.clear() 137 | lastMethodLineNumber = -1 138 | } 139 | 140 | private fun writeInstruction( 141 | instructionSet: InstructionSet, 142 | instruction: Instruction, 143 | indexedMethods: IntObjectMap, 144 | opCodeLength: Int 145 | ) { 146 | sb.append(" ".repeat(codeStyle.indent)) 147 | 148 | methodAddresses[instruction.address] = line 149 | if (instruction.jumpAddress > -1) { 150 | methodJumps.add(IntIntPair(line, instruction.jumpAddress)) 151 | } 152 | 153 | val lineNumber = instruction.lineNumber 154 | if (lineNumber > -1) { 155 | sourceToCodeLine[lineNumber] = line 156 | lastMethodLineNumber = lineNumber 157 | } 158 | 159 | codeToSourceToLine[line] = lastMethodLineNumber 160 | if (codeStyle.showLineNumbers) { 161 | val prefix = if (lineNumber > -1) "$lineNumber:" else " " 162 | sb.append(prefix.padEnd(codeStyle.lineNumberWidth + 2)) 163 | } 164 | 165 | instructions[line] = instruction 166 | 167 | sb.append(instruction.label) 168 | sb.append(": ") 169 | sb.append(instruction.op) 170 | if (instruction.operands.isNotEmpty()) { 171 | sb.append(if (opCodeLength != -1) " ".repeat(opCodeLength - instruction.op.length + 1) else " ") 172 | sb.append(instruction.operands) 173 | } 174 | 175 | if (instruction.callAddress != -1) { 176 | val set = if (instruction.callAddressMethod == -1) { 177 | instructionSet 178 | } else { 179 | indexedMethods[instruction.callAddressMethod]?.instructionSet 180 | } 181 | val callReference = set?.methodReferences?.get(instruction.callAddress) 182 | if (callReference != null) { 183 | sb.append(" → ").append(callReference.name) 184 | } 185 | } 186 | 187 | sb.append('\n') 188 | line++ 189 | } 190 | 191 | fun build(): Code { 192 | return Code(isa, sb.toString(), instructions, jumps, sourceToCodeLine, codeToSourceToLine) 193 | } 194 | 195 | override fun toString() = sb.toString() 196 | 197 | fun writeLine(text: String) { 198 | sb.append(text) 199 | sb.append('\n') 200 | line++ 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/CodeContent.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | import java.io.ByteArrayOutputStream 20 | import java.io.PrintStream 21 | 22 | sealed class CodeContent { 23 | data class Success(val classes: List) : CodeContent() 24 | data class Error(val errorText: String) : CodeContent() { 25 | constructor(e: Exception) : this(e.toFullString()) 26 | } 27 | data object Empty : CodeContent() 28 | } 29 | 30 | private fun Throwable.toFullString(): String { 31 | return ByteArrayOutputStream().use { 32 | printStackTrace(PrintStream(it)) 33 | it.toString() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/CodeStyle.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | data class CodeStyle( 20 | val indent: Int = 4, 21 | val showLineNumbers: Boolean = true, 22 | val lineNumberWidth: Int = 4, 23 | ) 24 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/CodeTextArea.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | import dev.romainguy.kotlin.explorer.SourceTextArea 20 | import dev.romainguy.kotlin.explorer.SyntaxTextArea 21 | import dev.romainguy.kotlin.explorer.centerCaretInView 22 | import dev.romainguy.kotlin.explorer.code.CodeContent.* 23 | import java.awt.BasicStroke 24 | import java.awt.Graphics 25 | import java.awt.Graphics2D 26 | import java.awt.RenderingHints 27 | import java.awt.event.MouseAdapter 28 | import java.awt.event.MouseEvent 29 | import java.awt.geom.GeneralPath 30 | import javax.swing.event.CaretEvent 31 | 32 | class CodeTextArea( 33 | codeStyle: CodeStyle, 34 | var isSyncLinesEnabled: Boolean, 35 | private val sourceTextArea: SourceTextArea?, 36 | var onLineSelected: ((code: Code, lineNumber: Int, content: String) -> Unit)? = null 37 | ) : SyntaxTextArea() { 38 | private var code: Code? = null 39 | private var jumpOffsets: JumpOffsets? = null 40 | private var content: CodeContent = Empty 41 | 42 | var codeStyle = codeStyle 43 | set(value) { 44 | val changed = value != field 45 | field = value 46 | if (changed) { 47 | updatePreservingCaretLine() 48 | } 49 | } 50 | 51 | init { 52 | addCaretListener(::caretUpdate) 53 | markOccurrences = true 54 | markOccurrencesDelay = 400 55 | 56 | if (sourceTextArea != null) { 57 | addMouseListener(object : MouseAdapter() { 58 | override fun mouseClicked(event: MouseEvent) { 59 | if (isSyncLinesEnabled) { 60 | val codeLine = getLineOfOffset(viewToModel2D(event.point)) 61 | val line = code?.getSourceLine(codeLine) ?: return 62 | if (line == -1) return 63 | sourceTextArea.gotoLine(this@CodeTextArea, line - 1) 64 | } 65 | } 66 | }) 67 | } 68 | } 69 | 70 | 71 | fun setContent(value: CodeContent) { 72 | content = value 73 | updateContent() 74 | } 75 | 76 | fun gotoSourceLine(sourceLine: Int) { 77 | val line = code?.getCodeLine(sourceLine + 1) ?: return 78 | if (line == -1) return 79 | caretPosition = getLineStartOffset(line.coerceIn(0 until lineCount)) 80 | centerCaretInView() 81 | } 82 | 83 | private fun updatePreservingCaretLine() { 84 | val line = getLineOfOffset(caretPosition) 85 | val oldText = text 86 | updateContent() 87 | if (oldText != text) { 88 | caretPosition = getLineStartOffset(line) 89 | caretUpdate(line) 90 | } 91 | } 92 | 93 | private fun updateContent() { 94 | val position = caretPosition 95 | code = null 96 | when (val content = content) { 97 | is Empty -> text = "" 98 | is Error -> text = content.errorText 99 | is Success -> code = Code.fromClasses(content.classes, codeStyle).also { 100 | val text = it.text 101 | if (text != this.text) { 102 | val killEdit = this.text.isEmpty() 103 | replaceRange(text, 0, this.text.length) 104 | if (killEdit) discardAllEdits() 105 | } 106 | } 107 | } 108 | caretPosition = minOf(position, document.length) 109 | } 110 | 111 | override fun paintComponent(g: Graphics?) { 112 | super.paintComponent(g) 113 | jumpOffsets?.let { jump -> 114 | val scale = if (presentationMode) 2 else 1 115 | val padding = 6 * scale 116 | val triangleSize = 8 * scale 117 | 118 | val bounds1 = modelToView2D(jump.src) 119 | val bounds2 = modelToView2D(jump.dst) 120 | 121 | val x1 = bounds1.x.toInt() - padding 122 | val y1 = (bounds1.y + lineHeight / 2).toInt() 123 | 124 | val delta = jump.dst - getLineStartOffset(getLineOfOffset(jump.dst)) 125 | val endPadding = if (codeStyle.showLineNumbers && delta < 4) 2 else padding 126 | 127 | val x2 = bounds2.x.toInt() - endPadding 128 | val y2 = (bounds2.y + lineHeight / 2).toInt() 129 | 130 | val x0 = modelToView2D(maxOf(codeStyle.indent * 2 - 4, 1)).x.toInt() 131 | 132 | val g2 = g as Graphics2D 133 | g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) 134 | g2.stroke = BasicStroke(scale.toFloat()) 135 | g2.drawLine(x1, y1, x0, y1) 136 | g2.drawLine(x0, y1, x0, y2) 137 | g2.drawLine(x0, y2, x2 - triangleSize / 2, y2) 138 | 139 | g2.fill(GeneralPath().apply { 140 | val fx = x2.toFloat() 141 | val fy = y2.toFloat() + 0.5f 142 | val fs = triangleSize.toFloat() 143 | moveTo(fx, fy) 144 | lineTo(fx - fs, fy - fs / 2.0f) 145 | lineTo(fx - fs, fy + fs / 2.0f) 146 | }) 147 | } 148 | } 149 | 150 | private fun caretUpdate(event: CaretEvent) { 151 | // We receive two events every time, make sure we react to only one 152 | if (event.javaClass.name != "org.fife.ui.rsyntaxtextarea.RSyntaxTextArea\$RSyntaxTextAreaMutableCaretEvent") { 153 | caretUpdate(getLineOfOffset(minOf(event.dot, document.length))) 154 | } 155 | } 156 | 157 | private fun caretUpdate(line: Int) { 158 | val codeModel = code ?: return 159 | val oldJumpOffsets = jumpOffsets 160 | 161 | val lineContent = getLine(line) 162 | if (onLineSelected != null) { 163 | onLineSelected?.invoke(code!!, line, lineContent) 164 | } 165 | 166 | try { 167 | jumpOffsets = null 168 | val dstLine = codeModel.getJumpTargetOfLine(line) 169 | if (dstLine == -1) return 170 | 171 | val srcOffset = getLineStartOffset(line) + lineContent.countPadding() 172 | val dstOffset = getLineStartOffset(dstLine) + getLine(dstLine).countPadding() 173 | jumpOffsets = JumpOffsets(srcOffset, dstOffset) 174 | } finally { 175 | if (jumpOffsets != oldJumpOffsets) { 176 | repaint() 177 | } 178 | } 179 | } 180 | 181 | private fun getLine(line: Int): String { 182 | val start = getLineStartOffset(line) 183 | val end = getLineEndOffset(line) 184 | return document.getText(start, end - start).trimEnd() 185 | } 186 | 187 | private data class JumpOffsets(val src: Int, val dst: Int) 188 | } 189 | 190 | private fun String.countPadding() = indexOfFirst { it != ' ' } 191 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/DataModels.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | import androidx.collection.* 20 | 21 | enum class ISA(val branchInstructions: ScatterSet, val returnInstructions: ScatterSet) { 22 | ByteCode(scatterSetOf("if"), scatterSetOf("areturn", "ireturn", "lreturn", "dreturn", "freturn", "return")), 23 | Dex(scatterSetOf("if"), scatterSetOf("return")), 24 | Oat(scatterSetOf(), scatterSetOf()), 25 | X86_64( 26 | scatterSetOf( 27 | "je", 28 | "jz", 29 | "jne", 30 | "jnz", 31 | "js", 32 | "jns", 33 | "jg", 34 | "jnle", 35 | "jge", 36 | "jnl", 37 | "jl", 38 | "jnge", 39 | "jle", 40 | "jng", 41 | "ja", 42 | "jnbe", 43 | "jae", 44 | "jnb", 45 | "jb", 46 | "jnae", 47 | "jbe", 48 | "jna" 49 | ), 50 | scatterSetOf("ret") 51 | ), 52 | Aarch64( 53 | scatterSetOf( 54 | "b", 55 | "b.eq", 56 | "b.ne", 57 | "b.cs", 58 | "b.hs", 59 | "b.cc", 60 | "b.lo", 61 | "b.mi", 62 | "b.pl", 63 | "b.vs", 64 | "b.vc", 65 | "b.hi", 66 | "b.ls", 67 | "b.ge", 68 | "b.lt", 69 | "b.gt", 70 | "b.le", 71 | "b.al", 72 | "bl", 73 | "cbz", 74 | "cbnz", 75 | "tbz", 76 | "tbnz" 77 | ), 78 | scatterSetOf("ret") 79 | ) 80 | } 81 | 82 | data class Class(val header: String, val methods: List, val builtIn: Boolean) 83 | 84 | const val AccessPublic = 0x00001 85 | const val AccessPrivate = 0x00002 86 | const val AccessStatic = 0x00008 87 | const val AccessFinal = 0x00010 88 | const val AccessSynthetic = 0x01000 89 | const val AccessConstructor = 0x10000 90 | 91 | data class Method( 92 | val header: String, 93 | val instructionSet: InstructionSet, 94 | val index: Int = -1, 95 | val codeSize: Int = -1 96 | ) 97 | 98 | data class InstructionSet( 99 | val isa: ISA, 100 | val instructions: List, 101 | val methodReferences: IntObjectMap = emptyIntObjectMap() 102 | ) 103 | 104 | data class Instruction( 105 | val address: Int, 106 | val label: String, 107 | val op: String, 108 | val operands: String, 109 | val jumpAddress: Int, 110 | val callAddress: Int = -1, 111 | val callAddressMethod: Int = -1, 112 | val lineNumber: Int = -1 113 | ) 114 | 115 | data class MethodReference(val address: Int, val name: String) 116 | 117 | fun List.withLineNumbers(lineNumbers: IntIntMap): List { 118 | return map { 119 | val line = lineNumbers.getOrDefault(it.address, -1) 120 | if (line != -1) it.copy(lineNumber = line) else it 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/Documentation.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:OptIn(ExperimentalJewelApi::class) 18 | 19 | package dev.romainguy.kotlin.explorer.code 20 | 21 | import androidx.collection.mutableScatterMapOf 22 | import kotlinx.coroutines.Dispatchers 23 | import kotlinx.coroutines.withContext 24 | import org.jetbrains.jewel.foundation.ExperimentalJewelApi 25 | import org.jetbrains.jewel.markdown.InlineMarkdown 26 | import org.jetbrains.jewel.markdown.MarkdownBlock 27 | import org.jetbrains.jewel.markdown.processing.MarkdownProcessor 28 | 29 | private val Conditions = mutableScatterMapOf( 30 | "eq" to "equal.", 31 | "ne" to "not equal.", 32 | "cs" to "carry set.", 33 | "cc" to "carry clear.", 34 | "mi" to "negative.", 35 | "pl" to "positive or zero.", 36 | "vs" to "overflow.", 37 | "vc" to "no overflow.", 38 | "hi" to "unsigned higher.", 39 | "ls" to "unsigned lower or same.", 40 | "ge" to "signed greater than or equal.", 41 | "lt" to "signed less than.", 42 | "gt" to "signed greater than.", 43 | "le" to "signed less than or equal." 44 | ) 45 | 46 | suspend fun MarkdownProcessor.generateInlineDocumentation(code: Code, line: Int): List { 47 | if (code.isa == ISA.Aarch64) { 48 | val fullOp = code.instructions[line]?.op ?: "" 49 | val op = fullOp.substringBefore('.') 50 | val opDocumentation = Aarch64Docs[op] 51 | if (opDocumentation != null) { 52 | return withContext(Dispatchers.Default) { 53 | val blocks = ArrayList() 54 | blocks += MarkdownBlock.Heading(2, InlineMarkdown.Text(opDocumentation.name)) 55 | 56 | val condition = fullOp.substringAfter('.', "") 57 | if (condition.isNotEmpty()) { 58 | val conditionDocumentation = Conditions[condition] 59 | if (conditionDocumentation != null) { 60 | blocks += MarkdownBlock.Paragraph( 61 | InlineMarkdown.StrongEmphasis("**", InlineMarkdown.Text("Condition: ")), 62 | InlineMarkdown.Text(conditionDocumentation) 63 | ) 64 | } 65 | } 66 | 67 | blocks += processMarkdownDocument(opDocumentation.documentation) 68 | 69 | blocks += MarkdownBlock.Paragraph( 70 | InlineMarkdown.Link( 71 | opDocumentation.url, 72 | "See full documentation", 73 | InlineMarkdown.Text("See full documentation") 74 | ) 75 | ) 76 | 77 | blocks 78 | } 79 | } 80 | } 81 | return emptyList() 82 | } 83 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/OpCodeDoc.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | data class OpCodeDoc(val name: String, val documentation: String, val url: String) 20 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/code/SyntaxStyle.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.code 18 | 19 | import org.fife.ui.rsyntaxtextarea.AbstractTokenMakerFactory 20 | import org.fife.ui.rsyntaxtextarea.TokenMakerFactory 21 | 22 | class SyntaxStyle private constructor() { 23 | companion object { 24 | val Dex: String get() = "text/dex-bytecode" 25 | val ByteCode: String get() = "text/java-bytecode" 26 | val Kotlin: String get() = "text/kotlin" 27 | val Oat: String get() = "text/oat-assembly" 28 | 29 | init { 30 | val factory = TokenMakerFactory.getDefaultInstance() as AbstractTokenMakerFactory 31 | factory.putMapping(ByteCode, DexTokenMaker::class.java.canonicalName) 32 | factory.putMapping(Dex, DexTokenMaker::class.java.canonicalName) 33 | factory.putMapping(Kotlin, KotlinTokenMaker::class.java.canonicalName) 34 | factory.putMapping(Oat, OatTokenMaker::class.java.canonicalName) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/dex/DexDumpParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.dex 18 | 19 | import androidx.collection.IntIntMap 20 | import androidx.collection.mutableIntIntMapOf 21 | import dev.romainguy.kotlin.explorer.BuiltInKotlinClass 22 | import dev.romainguy.kotlin.explorer.HexDigit 23 | import dev.romainguy.kotlin.explorer.code.* 24 | import dev.romainguy.kotlin.explorer.code.CodeContent.Error 25 | import dev.romainguy.kotlin.explorer.code.CodeContent.Success 26 | import dev.romainguy.kotlin.explorer.consumeUntil 27 | import dev.romainguy.kotlin.explorer.getValue 28 | import dev.romainguy.kotlin.explorer.oat.codeToOpAndOperands 29 | 30 | private val PositionRegex = Regex("^\\s*0x(?
[0-9a-f]+) line=(?\\d+)$") 31 | 32 | private val JumpRegex = Regex("^$HexDigit{4}: .* (?
$HexDigit{4}) // [+-]$HexDigit{4}$") 33 | private val AccessRegex = Regex("^access\\s+:\\s+0x(?[0-9a-fA-F]+)\\s+\\(.+\\)$") 34 | 35 | private const val ClassStart = "Class #" 36 | private const val ClassEnd = "source_file_idx" 37 | private const val ClassName = "Class descriptor" 38 | private const val Instructions = "insns size" 39 | private const val Positions = "positions" 40 | private const val Access = "access" 41 | 42 | internal class DexDumpParser { 43 | fun parse(text: String): CodeContent { 44 | println(text) 45 | return try { 46 | val lines = text.lineSequence().iterator() 47 | val classes = buildList { 48 | while (lines.consumeUntil(ClassStart)) { 49 | val clazz = lines.readClass() 50 | if (clazz != null && clazz.methods.isNotEmpty()) { 51 | add(clazz) 52 | } 53 | } 54 | } 55 | Success(classes) 56 | } catch (e: Exception) { 57 | Error(e) 58 | } 59 | } 60 | 61 | private fun Iterator.readClass(): Class? { 62 | val className = next().getClassName() 63 | if (className.matches(BuiltInKotlinClass)) { 64 | return null 65 | } 66 | val methods = buildList { 67 | var access = 0 68 | while (hasNext()) { 69 | val line = next().trim() 70 | if (line.startsWith(Access)) access = readAccess(line) 71 | when { 72 | line.startsWith(ClassEnd) -> break 73 | line.startsWith(Instructions) -> add(readMethod(className, access)) 74 | } 75 | } 76 | } 77 | return Class("class $className", methods, false) 78 | } 79 | 80 | private fun Iterator.readMethod(className: String, access: Int): Method { 81 | val (name, type) = next().substringAfterLast(".").split(':', limit = 2) 82 | val instructions = readInstructions() 83 | 84 | consumeUntil(Positions) 85 | 86 | val positions = readPositions() 87 | val returnType = returnTypeFromType(type) 88 | val paramTypes = paramTypesFromType(type).joinToString(", ") 89 | 90 | return Method( 91 | "$returnType $className.$name($paramTypes)${accessToString(access)}", 92 | InstructionSet(ISA.Dex, instructions.withLineNumbers(positions)) 93 | ) 94 | } 95 | 96 | private fun accessToString(access: Int) = if (access and AccessStatic != 0) " // static" else "" 97 | 98 | private fun readAccess(line: String): Int { 99 | val bitField = AccessRegex.matchEntire(line)?.getValue("bitField") 100 | return if (bitField != null) bitField.toInt(16) else 0 101 | } 102 | 103 | private fun Iterator.readInstructions(): List { 104 | return buildList { 105 | while (hasNext()) { 106 | val line = next() 107 | if (line[0] == ' ') { 108 | break 109 | } 110 | val code = line.substringAfter('|') 111 | val address = code.substringBefore(": ") 112 | val jumpAddress = JumpRegex.matchEntire(code)?.getValue("address") 113 | val (op, operands) = codeToOpAndOperands(code.substringAfter(": ")) 114 | add(Instruction(address.toInt(16), address, op, operands, jumpAddress?.toInt(16) ?: -1)) 115 | } 116 | } 117 | } 118 | 119 | private fun Iterator.readPositions(): IntIntMap { 120 | val map = mutableIntIntMapOf() 121 | while (hasNext()) { 122 | val line = next() 123 | val match = PositionRegex.matchEntire(line) ?: break 124 | map.put(match.getValue("address").toInt(16), match.getValue("line").toInt()) 125 | } 126 | return map 127 | } 128 | } 129 | 130 | private fun String.getClassName() = 131 | getValue(ClassName) 132 | .removePrefix("L") 133 | .removeSuffix(";") 134 | .replace('/', '.') 135 | 136 | private fun String.getValue(name: String): String { 137 | if (!trim().startsWith(name)) { 138 | throw IllegalStateException("Expected '$name'") 139 | } 140 | return substringAfter('\'').substringBefore('\'') 141 | } 142 | 143 | private fun paramTypesFromType(type: String): List { 144 | val types = mutableListOf() 145 | val paramTypes = type.substringAfter('(').substringBeforeLast(')') 146 | 147 | var i = 0 148 | while (i < paramTypes.length) { 149 | when (paramTypes[i]) { 150 | '[' -> { 151 | val result = jniTypeToJavaType(paramTypes, i + 1) 152 | types += result.first + "[]" 153 | i = result.second 154 | } 155 | else -> { 156 | val result = jniTypeToJavaType(paramTypes, i) 157 | types += result.first 158 | i = result.second 159 | } 160 | } 161 | } 162 | 163 | return types 164 | } 165 | 166 | private fun jniTypeToJavaType( 167 | type: String, 168 | index: Int 169 | ): Pair { 170 | var endIndex = index 171 | return when (type[index]) { 172 | 'B' -> "byte" 173 | 'C' -> "char" 174 | 'D' -> "double" 175 | 'F' -> "float" 176 | 'I' -> "int" 177 | 'J' -> "long" 178 | 'L' -> { 179 | endIndex = type.indexOf(';', index) 180 | type.substring(index + 1, endIndex) 181 | .replace('/', '.') 182 | .replace("java.lang.", "") 183 | } 184 | 'S' -> "short" 185 | 'V' -> "void" 186 | 'Z' -> "boolean" 187 | else -> "" 188 | } to endIndex + 1 189 | } 190 | 191 | private fun returnTypeFromType(type: String): String { 192 | val index = type.lastIndexOf(')') + 1 193 | return when (type[index]) { 194 | '[' -> { 195 | jniTypeToJavaType(type, index + 1).first + "[]" 196 | } 197 | else -> { 198 | jniTypeToJavaType(type, index).first 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/oat/OatDumpParser.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2024 Romain Guy 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy 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, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dev.romainguy.kotlin.explorer.oat 18 | 19 | import androidx.collection.IntObjectMap 20 | import androidx.collection.mutableIntObjectMapOf 21 | import dev.romainguy.kotlin.explorer.* 22 | import dev.romainguy.kotlin.explorer.code.* 23 | import dev.romainguy.kotlin.explorer.code.CodeContent.Error 24 | import dev.romainguy.kotlin.explorer.code.CodeContent.Success 25 | 26 | private val CodeStartRegex = Regex("^\\s+CODE: \\(code_offset=0x[a-fA-F0-9]+ size=(?\\d+)\\)([.]{3})?") 27 | 28 | private val ClassNameRegex = Regex("^\\d+: L(?[^;]+); \\(offset=0x$HexDigit+\\) \\(type_idx=\\d+\\).+") 29 | private val MethodRegex = Regex("^\\s+\\d+:\\s+(?.+)\\s+\\(dex_method_idx=(?\\d+)\\)") 30 | private val CodeRegex = Regex("^\\s+0x(?
$HexDigit+):\\s+$HexDigit+\\s+(?.+)") 31 | 32 | private val DexCodeRegex = Regex("^\\s+0x(?
$HexDigit+):\\s+($HexDigit+\\s+)+\\|\\s+(?.+)") 33 | private val DexMethodInvokeRegex = Regex("^invoke-[^}]+},\\s+\\S+\\s+(?.+)\\s+//.+") 34 | 35 | private val Aarch64JumpRegex = Regex(".+ #[+-]0x$HexDigit+ \\(addr 0x(?
$HexDigit+)\\)\$") 36 | private val X86JumpRegex = Regex(".+ [+-]\\d+ \\(0x(?
$HexDigit{8})\\)\$") 37 | 38 | private val Aarch64MethodCallRegex = Regex("^blr lr$") 39 | private val X86MethodCallRegex = Regex("^TODO$") // TODO: implement x86 40 | 41 | private val DexMethodReferenceRegex = Regex("^\\s+StackMap.+dex_pc=0x(?$HexDigit+),.+$") 42 | private val DexInlineInfoRegex = Regex("^\\s+InlineInfo.+dex_pc=0x(?$HexDigit+),\\s+method_index=(?$HexDigit+).+$") 43 | 44 | internal class OatDumpParser { 45 | private var isa = ISA.Aarch64 46 | 47 | fun parse(text: String): CodeContent { 48 | return try { 49 | val lines = PeekingIterator(text.lineSequence().iterator()) 50 | val isa = when (val set = lines.readInstructionSet()) { 51 | "Arm64" -> ISA.Aarch64 52 | "X86_64" -> ISA.X86_64 53 | else -> throw IllegalStateException("Unknown instruction set: $set") 54 | } 55 | val jumpRegex = when (isa) { 56 | ISA.Aarch64 -> Aarch64JumpRegex 57 | ISA.X86_64 -> X86JumpRegex 58 | else -> throw IllegalStateException("Incompatible ISA: $isa") 59 | } 60 | val methodCallRegex = when (isa) { 61 | ISA.Aarch64 -> Aarch64MethodCallRegex 62 | ISA.X86_64 -> X86MethodCallRegex 63 | else -> throw IllegalStateException("Incompatible ISA: $isa") 64 | } 65 | val classes = buildList { 66 | while (lines.hasNext()) { 67 | val match = lines.consumeUntil(ClassNameRegex) ?: break 68 | val clazz = lines.readClass( 69 | match.getValue("class").replace('/', '.'), 70 | jumpRegex, 71 | methodCallRegex 72 | ) 73 | if (clazz != null && clazz.methods.isNotEmpty()) { 74 | add(clazz) 75 | } 76 | } 77 | } 78 | Success(classes) 79 | } catch (e: Exception) { 80 | Error(e) 81 | } 82 | } 83 | 84 | private fun PeekingIterator.readInstructionSet(): String { 85 | consumeUntil("INSTRUCTION SET:") 86 | return next() 87 | } 88 | 89 | private fun PeekingIterator.readClass( 90 | className: String, 91 | jumpRegex: Regex, 92 | methodCallRegex: Regex 93 | ): Class? { 94 | val builtIn = className.matches(BuiltInKotlinClass) 95 | val methods = buildList { 96 | while (hasNext()) { 97 | val line = peek() 98 | when { 99 | ClassNameRegex.matches(line) -> break 100 | else -> { 101 | // Skip to the next line first and then read the method 102 | next() 103 | 104 | val match = MethodRegex.matchEntire(line) 105 | if (match != null) { 106 | add(readMethod(match, jumpRegex, methodCallRegex, builtIn)) 107 | } 108 | } 109 | } 110 | } 111 | } 112 | return Class("class $className", methods, builtIn) 113 | } 114 | 115 | private fun PeekingIterator.readMethod( 116 | match: MatchResult, 117 | jumpRegex: Regex, 118 | methodCallRegex: Regex, 119 | builtIn: Boolean = false 120 | ): Method { 121 | consumeUntil("DEX CODE:") 122 | val methodReferences = readMethodReferences() 123 | 124 | val codeStart = consumeUntil(CodeStartRegex) 125 | val codeSize = codeStart?.getValue("codeSize")?.toInt() ?: -1 126 | 127 | // We could also check for the presence of "..." at the end of codeStart 128 | val instructions = if (codeSize == 0) { 129 | // Skip "NO CODE!" 130 | next() 131 | emptyList() 132 | } else { 133 | readNativeInstructions(jumpRegex, methodCallRegex, builtIn) 134 | } 135 | 136 | val method = match.getValue("method") 137 | val index = match.getValue("methodIndex").toInt() 138 | 139 | return Method(method, InstructionSet(isa, instructions, methodReferences), index, codeSize) 140 | } 141 | 142 | private fun PeekingIterator.readMethodReferences(): IntObjectMap { 143 | val map = mutableIntObjectMapOf() 144 | while (hasNext()) { 145 | val match = DexCodeRegex.matchEntire(next()) 146 | if (match != null) { 147 | match.toMethodReference()?.apply { 148 | map[address] = this 149 | } 150 | } else { 151 | break 152 | } 153 | } 154 | return map 155 | } 156 | 157 | private fun MatchResult.toMethodReference(): MethodReference? { 158 | val code = getValue("code") 159 | val nameMatch = DexMethodInvokeRegex.matchEntire(code) 160 | if (nameMatch != null) { 161 | val address = getValue("address") 162 | val name = nameMatch.getValue("name") 163 | val pc = address.toInt(16) 164 | return MethodReference(pc, name) 165 | } 166 | return null 167 | } 168 | 169 | private fun PeekingIterator.readNativeInstructions( 170 | jumpRegex: Regex, 171 | methodCallRegex: Regex, 172 | builtIn: Boolean 173 | ): List { 174 | return buildList { 175 | while (hasNext()) { 176 | val line = peek() 177 | when { 178 | line.matches(MethodRegex) -> break 179 | line.matches(ClassNameRegex) -> break 180 | else -> { 181 | val match = CodeRegex.matchEntire(next()) 182 | if (match != null) { 183 | if (builtIn) continue 184 | add( 185 | readNativeInstruction( 186 | this@readNativeInstructions, 187 | match, 188 | jumpRegex, 189 | methodCallRegex 190 | ) 191 | ) 192 | } 193 | } 194 | } 195 | } 196 | } 197 | } 198 | 199 | private fun readNativeInstruction( 200 | iterator: PeekingIterator, 201 | match: MatchResult, 202 | jumpRegex: Regex, 203 | methodCallRegex: Regex 204 | ): Instruction { 205 | val address = match.getValue("address") 206 | val code = match.getValue("code") 207 | 208 | var callAddress = if (methodCallRegex.matches(code)) { 209 | DexMethodReferenceRegex.matchEntire(iterator.peek())?.getValue("callAddress")?.toInt(16) ?: -1 210 | } else { 211 | -1 212 | } 213 | 214 | val callAddressMethod = if (callAddress != -1) { 215 | // Skip the StackMap line 216 | iterator.next() 217 | // Check the InlineInfo if present 218 | var index = -1 219 | do { 220 | val methodIndex = DexInlineInfoRegex.matchEntire(iterator.peek()) 221 | if (methodIndex != null) { 222 | callAddress = methodIndex.getValue("callAddress").toInt(16) 223 | index = methodIndex.getValue("methodIndex").toInt() 224 | iterator.next() 225 | } 226 | } while (methodIndex != null) 227 | index 228 | } else { 229 | -1 230 | } 231 | 232 | val jumpAddress = if (callAddress == -1) { 233 | jumpRegex.matchEntire(code)?.getValue("address")?.toInt(16) ?: -1 234 | } else { 235 | -1 236 | } 237 | 238 | val codeAddress = address.toInt(16) 239 | val (op, operands) = codeToOpAndOperands(code) 240 | return Instruction(codeAddress, "0x$address", op, operands, jumpAddress, callAddress, callAddressMethod) 241 | } 242 | } 243 | 244 | internal fun codeToOpAndOperands(code: String): Pair { 245 | val index = code.indexOf(' ') 246 | val opCode = if (index >= 0) code.substring(0, index) else code 247 | val operands = if (index >= 0) code.substring(index + 1).trim() else "" 248 | return Pair(opCode, operands) 249 | } 250 | -------------------------------------------------------------------------------- /src/jvmMain/resources/icons/done.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/jvmMain/resources/icons/error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/jvmMain/resources/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romainguy/kotlin-explorer/d783c4acc16ac82655c41760568f3be6181db9c4/src/jvmMain/resources/icons/icon.ico -------------------------------------------------------------------------------- /src/jvmMain/resources/themes/kotlin_explorer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |