diff --git a/.github/workflows/gradle_build.yml b/.github/workflows/gradle_build.yml deleted file mode 100644 index 1a220dc4..00000000 --- a/.github/workflows/gradle_build.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Build Jar - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 8 - uses: actions/setup-java@v4 - with: - java-version: '8' - distribution: 'temurin' - - uses: gradle/actions/setup-gradle@v4 - - name: Build with Gradle - run: ./gradlew fatJar - - uses: actions/upload-artifact@v4 - with: - name: APKEditor - path: build/libs diff --git a/.github/workflows/gradle_build_beta.yml b/.github/workflows/gradle_build_beta.yml index d8fecad6..636beadf 100644 --- a/.github/workflows/gradle_build_beta.yml +++ b/.github/workflows/gradle_build_beta.yml @@ -16,14 +16,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 8 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '8' + java-version: '21' distribution: 'temurin' - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.5' - name: Build with Gradle - run: ./gradlew betaJar + run: gradle fatJar --no-daemon - uses: actions/upload-artifact@v4 with: name: APKEditor diff --git a/.gitignore b/.gitignore index 0afd022d..612af5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Project exclude paths /.gradle/ /build/ -/build/classes/java/main/ -/.github/ \ No newline at end of file +/.github/ diff --git a/.img/ui.jpg b/.img/ui.jpg new file mode 100755 index 00000000..87283970 Binary files /dev/null and b/.img/ui.jpg differ diff --git a/.img/ui_last.png b/.img/ui_last.png new file mode 100755 index 00000000..bf9c93a0 Binary files /dev/null and b/.img/ui_last.png differ diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1 @@ + diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 717f3f06..a3aa669a --- a/README.md +++ b/README.md @@ -1,558 +1,92 @@ -
👈 Click arrows to expand/collapse details on this page
- -# APKEditor -### Powerful android apk resources editor -This tool uses [ARSCLib](https://github.com/REAndroid/ARSCLib) to edit any apk resources and has six main features - -
java -jar APKEditor.jar -h - -```ShellSession -$ java -jar APKEditor.jar -h -APKEditor - x.x.x -https://github.com/REAndroid/APKEditor -Android binary resource files editor -Commands: - d | decode Decodes android resources binary to readable json/xml/raw. - b | build Builds android binary from json/xml/raw. - m | merge Merges split apk files from directory or compressed apk files - like XAPK, APKM, APKS ... - x | refactor Refactors obfuscated resource names - p | protect Protects/Obfuscates apk resource files. Using unique - obfuscation techniques. - info Prints information of apk. -Other options: - -h | -help Displays this help and exit - -v | -version Displays version information and exit - -To get help about each command run with: - -h +

+ ꧁𝔊 𝔄𝔓𝔎𝔈𝔡𝔦𝔱𝔬𝔯𝔉𝔲𝔱𝔲𝔯𝔦𝔰𝔱𝔦𝔠 𝔊꧂ - Interface +

+

+ Español | English +

+ + +## Description + +APKEditor Futuristic is a modern graphical interface that wraps the original [APKEditor](https://github.com/REAndroid/APKEditor) engine. + +## Final goal + +![ui_last](./.img/ui_last.png) + +> [!WARNING] +> Work in progress. + +## Build + +Executable GUI jar: + +```bash +sh gradlew guiFatJar --offline ``` -
- -#### 1- Decompile -* Decompiles resources of apk to human readable json string. -* Decompiles resources of apk to XML source code (for un-obfuscated apk only). Use ``` -t xml ``` -
java -jar APKEditor.jar d -i path/to/your-file.apk - -```ShellSession -$ java -jar APKEditor.jar d -i test.apk -o test_json -00.000 I: [DECOMPILE] Using: APKEditor version 1.4.3, ARSCLib version 1.3.6 - -t = xml - -load-dex = 3 - -dex-lib = jf - -i = test.apk - -o = test_json - _________________________ -00.006 I: [DECOMPILE] Loading ... -00.101 I: [DECOMPILE] Decompiling to xml ... -00.264 I: [DECOMPILE] Initializing android framework ... -00.264 I: [DECOMPILE] Loading android framework for version: 34 -00.304 I: [DECOMPILE] Initialized framework: android-34 (14) -00.320 I: [DECOMPILE] [SANITIZE]: Sanitizing paths ... -00.341 I: [DECOMPILE] Validating resource names ... -00.396 I: [DECOMPILE] All resource names are valid -00.396 I: [DECOMPILE] Decode: archive-info.json -00.398 I: [DECOMPILE] Decode: uncompressed-files.json -00.412 I: [DECOMPILE] Decoding: AndroidManifest.xml -00.450 I: [DECOMPILE] public.xml: com.test.name -> package_1 -00.508 I: [DECOMPILE] Res files: resources -02.483 I: [DECOMPILE] Baksmali: classes.dex -05.041 I: [DECOMPILE] Baksmali: classes2.dex -12.643 I: [DECOMPILE] Extracting root files ... -12.766 I: [DECOMPILE] Dumping signatures ... -12.766 I: [DECOMPILE] Saved to: test_json -``` -
more info - -```ShellSession -Decodes android resources binary to readable json/xml/raw. -Usage: - d [Options, flags] -Options: - -framework Path of framework file (can be multiple). - -framework-version Preferred framework version number - -i Input path. - -load-dex Number of dex files to load at a time. - If the apk dex files count greater than this value, - then the decoder loads one dex at a time. - *Applies only when -dex-lib set to internal. - *Default = 3 - *See below. - -o Output path. Optional, if not provided then a new file - will be generated on same directory as input - -remove-annotation [Baksmali] Type name of annotation to remove from dex - e.g: -remove-annotation Lkotlin/Metadata; - *Can be multiple. - -res-dir Sets resource files root dir name. e.g. for obfuscation - to move files from 'res/*' to 'r/*' or vice versa. - -sig Signatures directory path. - -t Decode types: - [xml, json, raw, sig] - -dex-lib Dex library to use: - 1) internal : Use internal library, supports dex - versions up to 042. - 2) jf : Use library by JesusFreke/smali, supports dex - versions 035 and below. - *Default = internal - *See below. - [internal, jf] - -comment-level [Baksmali] Sets the comment level. - 1) off : No comment will be printed. - 2) basic : Prints basic and resource id comments. - 3) detail : Including , Prints class & method - relation comments. - 4) detail2 : Including , Prints encoded hex - strings with literal as comment. - 5) full : Prints all comments. - **Default = detail - [off, basic, detail, detail2, full] -Flags: - -dex Copy raw dex files / skip smali. - -dex-markers Dumps dex markers (applies only when smali mode). - -dex-profile Decodes dex profile binary files under assets/dexopt to - readable json files. - -f Force delete output path. - -h | -help | --help Displays this help and exit. - -keep-res-path Keeps original res/* file paths: - *Applies only when decoding to xml - *All res/* files will be placed on dir - *The relative paths will be linked to values/*xml - -no-cache Do not create dex .cache files. - -no-dex-debug Drops all debug info from smali/dex. - -smali-registers [Baksmali] Use ".registers" directive instead of the - default ".locals". - -split-json Splits resources.arsc into multiple parts as per type - entries (use this for large files) - -vrd Validate resources dir name - (eg. if a drawable resource file path is 'res/abc.png' - then it will be moved to 'res/drawable/abc.png)' -Examples: - 1) [Basic] - java -jar APKEditor.jar d -i path/input.apk - 2) [Specify output] - java -jar APKEditor.jar d -i path/input.apk -o path/output.apk - 3) [Specify decode type] - java -jar APKEditor.jar d -t xml -i path/input.apk - 4) [Specify framework file(s)] - java -jar APKEditor.jar d -i path/input.apk -framework framework-res.apk - -framework platforms/android-32/android.jar - 5) [Decode apk signature block] - java -jar APKEditor.jar d -t sig -i path/input.apk -sig path/signatures_dir -Notes: - 1) [internal] Dex builder: - * Fully supports dex files up to 042. - * Highest dex file compression. - * Builds with similar dex-section order as r8/dx. - * Convenient dex markers editing, see file smali/classes/dex-file.json - * Additional helpful smali comments: e.g class/method hierarchy. - * Supports whitespaces on class simple name as introduced on dex 040+ - 2) [-load-dex] To print correct class/method hierarchy, it is necessary to - load all dex files at once. This may result high memory consumption and - could fail with "OutOfMemoryError" thus you are required to limit the - number of dex files to load at a time. You can overcome this problem with - -Xmx memory arg e.g java -Xmx8g -jar APKEditor.jar ... -``` -
-
- -#### 2- Build -Builds back to apk from decompiled json/XML files -
java -jar APKEditor.jar b -i path/to/decompiled-directory - -```ShellSession -$ java -jar APKEditor.jar b -i test_json -o test_edited.apk -00.000 I: [BUILD] Using: APKEditor version 1.4.3, ARSCLib version 1.3.6 - -t = xml - -dex-lib = jf - -i = test_json - -o = test_edited.apk - ______________________________ -00.005 I: [BUILD] Scanning XML directory ... -00.024 I: [BUILD] Scanning: test_json -00.168 I: [BUILD] Initializing android framework ... -00.168 I: [BUILD] Loading android framework for version: 34 -00.254 I: [BUILD] Initialized framework: android-34 (14) -00.254 I: [BUILD] Set main package id from manifest: @mipmap/ic_launcher -00.256 I: [BUILD] Main package id initialized: id = 0x7f, from: @mipmap/ic_launcher -00.256 I: [BUILD] Encoding attrs ... -00.307 I: [BUILD] Encoding values ... -00.560 I: [BUILD] Scan: package_1/res -00.661 I: [BUILD] Scanned 5718 files: package_1/res -00.852 I: [BUILD] Add manifest: AndroidManifest.xml -00.852 I: [BUILD] Building dex ... -00.960 I: [BUILD] (1/2) Cached: classes.dex -00.973 I: [BUILD] (2/2) Cached: classes2.dex -01.033 I: [BUILD] Scanning root directory ... -01.036 I: [BUILD] Restoring original file paths ... -01.078 I: [BUILD] Loading signatures ... -01.082 I: [BUILD] Sorting files ... -01.104 I: [BUILD] Refreshing resource table ... -01.163 I: [BUILD] TableBlock: packages = 1, size = 3067996 bytes -01.164 I: [BUILD] Applying: extractNativeLibs=false -01.164 I: [BUILD] Writing apk... -01.185 I: [BUILD] Buffering compress changed files ... -06.083 I: [BUILD] Writing files: 6637 -06.201 I: [BUILD] Writing signature block ... -06.241 I: [BUILD] Saved to: test_edited.apk -``` -
more info - -```ShellSession -Builds android binary from json/xml/raw. -Options: - -framework Path of framework file (can be multiple). - -framework-version Preferred framework version number - -i Input path. - -o Output path. Optional, if not provided then a new file - will be generated on same directory as input - -res-dir Sets resource files root dir name. e.g. for obfuscation - to move files from 'res/*' to 'r/*' or vice versa. - -sig Signatures directory path. - -t Build types, By default build types determined by quick - scanning of input directory files. Values are: - [xml, json, raw, sig] - -extractNativeLibs Sets extractNativeLibs attribute on manifest and - applies compression of library files (*.so). - *Default = manifest - 1) manifest: read and apply from manifest. - 2) none: remove attribute from manifest and store - libraries compressed. - 3) true: set manifest attribute 'true' and store - libraries compressed. - 4) false: set manifest attribute 'false' and store - libraries un-compressed with 4096 alignment. - [manifest, none, false, true] - -dex-lib Dex library to use: - 1) internal : Use internal library, supports dex - versions up to 042. - 2) jf : Use library by JesusFreke/smali, supports dex - versions 035 and below. - *Default = internal - *See below. - [internal, jf] -Flags: - -dex-profile Encodes decoded dex profile json files to binary - assets/dexopt files. - -f Force delete output path. - -h | -help | --help Displays this help and exit. - -no-cache Ignore built cached .dex files and re-build smali files. - -vrd Validate resources dir name - (eg. if a drawable resource file path is 'res/abc.png' - then it will be moved to 'res/drawable/abc.png)' -Examples: - 1) [Basic] - java -jar APKEditor.jar b -i path/input_directory - 2) [Specify output] - java -jar APKEditor.jar b -i path/input_directory -o path/output.apk - 3) [Restore signatures] - java -jar APKEditor.jar b -t sig -i path/input.apk -sig - path/signatures_dir - 4) [Specify framework] - java -jar APKEditor.jar b -i path/input_directory -framework - framework-res.apk -framework platforms/android-32/android.jar -``` -
-
- -#### 3- Merge -Merges multiple splitted apk files (directory, xapk, apkm, apks ...) to standalone apk -
java -jar APKEditor.jar m -i path/to/input - - ```ShellSession -$ java -jar APKEditor.jar m -i apk_files -00.000 I: [MERGE] Using: APKEditor version 1.4.3, ARSCLib version 1.3.6 - -i = apk_files - -o = apk_files_merged.apk - _____________________________ -00.009 I: [MERGE] Searching apk files ... -00.011 I: [MERGE] Found apk files: 3 -00.214 I: [MERGE] Found modules: 3 -00.329 I: [MERGE] Merging: base -00.331 I: [MERGE] Added [base] classes.dex -> classes.dex -00.331 I: [MERGE] Added [base] classes2.dex -> classes2.dex -01.289 I: [MERGE] Merging: config.arm64_v8a -01.293 I: [MERGE] Merging: config.xxhdpi -01.634 I: [MERGE] Sanitizing manifest ... -01.637 I: [MERGE] Removed-attribute : splitTypes -01.637 I: [MERGE] Removed-attribute : requiredSplitTypes -01.640 I: [MERGE] Attributes on removed: 0x01010591 (isSplitRequired) -01.641 I: [MERGE] Removed-element : name="com.android.vending.splits.required" -01.641 I: [MERGE] Removed-element : name="com.android.stamp.source" -01.641 I: [MERGE] Removed-element : name="com.android.stamp.type" -01.645 I: [MERGE] Removed-table-entry : res/xml/splits0.xml -01.646 I: [MERGE] Removed-element : name="com.android.vending.splits" -01.646 I: [MERGE] Removed-element : name="com.android.vending.derived.apk.id" -01.779 I: [MERGE] Applying: extractNativeLibs=false -01.779 I: [MERGE] Writing apk ... -01.806 I: [MERGE] Buffering compress changed files ... -04.766 I: [MERGE] Writing files: 6637 -04.981 I: [MERGE] Writing signature block ... -05.035 I: [MERGE] Saved to: apk_files_merged.apk -``` -![apkmerger](/.github/apkmerger.png) - -
more info - -```ShellSession -Merges split apk files from directory or compressed apk files like XAPK, -APKM, APKS ... -Options: - -i Input path. - -o Output path. Optional, if not provided then a new file - will be generated on same directory as input - -res-dir Sets resource files root dir name. e.g. for obfuscation - to move files from 'res/*' to 'r/*' or vice versa. - -extractNativeLibs Sets extractNativeLibs attribute on manifest and - applies compression of library files (*.so). - *Default = manifest - 1) manifest: read and apply from manifest. - 2) none: remove attribute from manifest and store - libraries compressed. - 3) true: set manifest attribute 'true' and store - libraries compressed. - 4) false: set manifest attribute 'false' and store - libraries un-compressed with 4096 alignment. - [manifest, none, false, true] -Flags: - -clean-meta Cleans META-INF directory along with signature block. - -f Force delete output path. - -h | -help | --help Displays this help and exit. - -validate-modules Validates for same versionNumber of base.apk with split - apk files. - -vrd Validate resources dir name - (eg. if a drawable resource file path is 'res/abc.png' - then it will be moved to 'res/drawable/abc.png)' -Examples: - 1) [Basic] - java -jar APKEditor.jar m -i path/input -o path/output.apk + +The GUI jar is generated at: + +```text +build/libs/APKEditor-1.4.9-gui.jar ``` -
-
- -#### 4- Refactor -Refactors obfuscated resource entry names -
java -jar APKEditor.jar x -i path/to/input.apk - - ```ShellSession -$ java -jar APKEditor.jar x -i input.apk -00.000 I: [REFACTOR] Using: APKEditor version 1.4.3, ARSCLib version 1.3.6 - -i = input.apk - -o = input_refactored.apk - _____________________________ -00.006 I: [REFACTOR] Loading apk: input.apk -00.105 I: [REFACTOR] Auto refactoring ... -00.105 I: [REFACTOR] Validating resource names ... -00.279 I: [REFACTOR] Initializing android framework ... -00.279 I: [REFACTOR] Loading android framework for version: 34 -00.320 I: [REFACTOR] Initialized framework: android-34 (14) -00.364 I: [REFACTOR] All resource names are valid -00.364 I: [REFACTOR] Validating file paths ... -00.455 I: [REFACTOR] Auto renamed entries -00.644 I: [REFACTOR] Removed unused table strings -Table size changed = 3067996, 3282324 -00.644 I: [REFACTOR] Writing apk ... -00.663 I: [REFACTOR] Buffering compress changed files ... -01.587 I: [REFACTOR] Writing files: 6637 -01.729 I: [REFACTOR] Writing signature block ... -01.783 I: [REFACTOR] Saved to: input_refactored.apk -``` -
more info - -```ShellSession -Refactors obfuscated resource names -Options: - -i Input path. - -o Output path. Optional, if not provided then a new file - will be generated on same directory as input - -public-xml Path of resource ids xml file (public.xml) - Loads names and applies to resources from 'public.xml' - file -Flags: - -clean-meta Cleans META-INF directory along with signature block. - -f Force delete output path. - -fix-types Corrects resource type names based on usages and values - -h | -help | --help Displays this help and exit. -Examples: - 1) [Basic] - java -jar APKEditor.jar x -i path/input.apk -o path/output.apk + +A general fat jar is also available (CLI + GUI): + +```bash +sh gradlew fatJar --offline ``` -
-
- -#### 5- Protect -Protects apk resources against almost all known decompile/modify tools. -
java -jar APKEditor.jar p -i path/to/input.apk - - ```ShellSession -00.000 I: [PROTECT] Using: APKEditor version 1.4.3, ARSCLib version 1.3.6 - -keep-type = font - -i = test.apk - -o = test_protected.apk - ___________________________________ -00.178 I: [PROTECT] DirectoryConfuser: Confusing ... -00.519 I: [PROTECT] FileNameConfuser: Confusing ... -00.602 I: [PROTECT] TableConfuser: Confusing ... -00.758 I: [PROTECT] TableConfuser: Type names ... -00.831 I: [PROTECT] Writing apk ... -00.858 I: [PROTECT] Buffering compress changed files ... -01.794 I: [PROTECT] Writing files: 6637 -01.931 I: [PROTECT] Writing signature block ... -01.987 I: [PROTECT] Saved to: test_protected.apk -``` -
more info - -```ShellSession -Protects/Obfuscates apk resource files. Using unique obfuscation techniques. -Options: - -dex-level Dex protection level (0-5). - 0 - Off - 1 - Basic - 2 - Normal (Not implemented yet) - 3 - High (Not implemented yet) - 4 - Max (Not implemented yet) - * Default 0 - -dic-dir-names Path to a text file containing a list of directory - names separated by new line. - -dic-file-names Path to a text file containing a list of file names - separated by new line. - -i Input path. - -keep-type Keep specific resource type names (e.g drawable), By - default keeps only resource type. - *Can be multiple - -o Output path. Optional, if not provided then a new file - will be generated on same directory as input -Flags: - -confuse-zip Confuse zip structure. When this turned on: - * Apps might crash if accessing apk files directly e.g - Class.getResourceAsStream(). - * Some apk scanners might flag it as "Malformed zip" - -f Force delete output path. - -h | -help | --help Displays this help and exit. - -skip-manifest Do not protect manifest. -Examples: - 1) [Basic] - java -jar APKEditor.jar p -i path/input.apk -o path/output.apk + +```text +build/libs/APKEditor-1.4.9-all.jar ``` -
-
- -#### 6- Info (⭐NEW⭐) -Prints/dumps from basic up to detailed information of apk. -
java -jar APKEditor.jar info -v -resources -i input.apk - - ```ShellSession -Package name=com.mypackage id=0x7f - type string id=1 entryCount=1 - resource 0x7f010000 string/app_name - () My Application - (-de) Meine Bewerbung - (-ru-rRU) Мое заявление - type mipmap id=2 entryCount=1 - resource 0x7f020000 mipmap/ic_launcher_round - () res/mipmap/ic_launcher_round.png - type drawable id=3 entryCount=1 - resource 0x7f030000 drawable/ic_launcher - () #006400 - -``` -
more info - -```ShellSession -Prints information of apk. -Options: - -filter-type Prints only the specified resource type names - *This applies only when flag '-resources' used. - *Can be multiple. - -framework Path of framework file (can be multiple). - -framework-version Preferred framework version number - -i Input path. - -o Output path. Optional, if not provided then a new file - will be generated on same directory as input - -res Prints resource entries specified by either of: - 1) Hex or decimal resource id. - 2) Full resource name e.g @string/app_name. - *Can be multiple. - -xmlstrings Print the strings of the given compiled xml assets. - *Can be multiple - -xmltree Prints the compiled xmls in the given assets. - *Can be multiple - -t Print types/formats: - [text, json, xml] -Flags: - -activities Prints main activity class name. If verbose mode, - prints all declared activities including - . - -app-class Application class name. - -app-icon App icon path/value. If verbose mode, prints all - configurations. - -app-name App name. If verbose mode, prints all configurations. - -app-round-icon App round icon path/value. If verbose mode, prints all - configurations. - -configurations Print the configurations in the APK. - -dex Prints dex information. - -f Force delete output path. - -h | -help | --help Displays this help and exit. - -languages Print the languages in the APK. - -list-files List files inside apk. - -list-xml-files List compiled xml files inside apk. - -locales Print the locales in the APK. - -min-sdk-version Minimum SDK version. - -package Package name(application id) from manifest and if - verbose mode, prints resource table packages. - -permissions Permissions. - -resources Prints all resources - -signatures Prints signature information. - -signatures-base64 Prints signature information with base64 certificates. - -strings Print the contents of the resource table string pool in - the APK. - -target-sdk-version Target SDK version. - -v Verbose mode. - -version-code App version code. - -version-name App version name. -Examples: - 1) [Basic] - java -jar APKEditor.jar info -i file.apk - 2) [Specify output and type] - java -jar APKEditor.jar info -i path/input.apk -t json -v -o - info_file.json - 3) [Print only specific type] - java -jar APKEditor.jar info -i path/input.apk -resources -filter-type - drawable + +> Use `sh gradlew` if `./gradlew` fails with `permission denied`. + +## Launch the graphical interface + +### Option 1 - Development classpath + +```bash +sh gradlew compileJava --offline + +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication ``` -
-
+### Option 2 - GUI jar ---- +```bash +sh gradlew guiFatJar --offline -***Build executable jar*** -
./gradlew fatJar +java -jar build/libs/APKEditor-1.4.9-gui.jar +``` - ```ShellSession - -# NB: Due to my lazyness , the dependency ARSCLib.jar is pre-built and placed under APKEditor/libs/ARSCLib.jar or you can build yourself and replace it. -git clone https://github.com/REAndroid/APKEditor -cd APKEditor -./gradlew fatJar -# Executable jar will be placed ./build/libs/APKEditor-x.x.x.jar +### Termux with VNC - ``` -
+```bash +cd /data/data/com.termux/files/home/proyecto/APKEditor -***Downloads*** -* [Latest release with pre-built executable jar](https://github.com/REAndroid/APKEditor/releases/latest) +export DISPLAY=:0 +export XAUTHORITY="$HOME/.Xauthority" -***Contribute*** -* Everyone is so welcome in this project, if you have some code improvements please make a pull request -* Please share your ideas / thoughts in [discussions](https://github.com/REAndroid/APKEditor/discussions) -* Please create issue you faced while using this tool along with your apk +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` +Then open the VNC viewer at `localhost:5900` or the configured port. +## Quick start -
Contact +1. Open the GUI. +2. Click the APK selector or press `Ctrl+O`. +3. Choose an APK/ZIP file. +4. Browse the tree, open files in the editor, and check the logs. +5. Press `F5` or click **Decompile** to generate the project. +6. Use the **Build**, **Merge**, **Refactor**, and **Protect** views to run the engine operations. +7. Switch between Spanish and English from the top bar or **Settings**. -* Telegram: [@kikfox](https://t.me/kikfox) -* Email: [thekikfox@gmail.com](mailto:thekikfox@gmail.com) +## Status -
+The current interface is implemented with Swing + Java2D. It includes live theme switching and Spanish/English language switching. Details about modules, shortcuts, themes, language, operations, and limitations are available in [dev-ui](./docs/dev-ui.md). diff --git a/build.gradle b/build.gradle old mode 100755 new mode 100644 index cae105fb..c5bffb95 --- a/build.gradle +++ b/build.gradle @@ -1,58 +1,75 @@ +plugins { + id 'application' + id 'java' +} -apply plugin: 'java' +group = 'com.reandroid.apkeditor' +version = '1.4.9' -group 'com.reandroid.apkeditor' -version '1.4.9' +application { + mainClass = 'com.reandroid.apkeditor.Main' +} java { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + // Definir la compatibilidad con Java 21 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } -if (JavaVersion.current().isJava8Compatible()) { - allprojects { - tasks.compileJava { - //options.addStringOption('-Xlint:unchecked', '-quiet') - } - } -} repositories { mavenCentral() } dependencies { - //implementation("io.github.reandroid:ARSCLib:+") - compile(files("$rootProject.projectDir/libs/ARSCLib.jar")) - - // built from: https://github.com/REAndroid/smali-lib - compile(files("$rootProject.projectDir/libs/smali.jar")) - - // built from: https://github.com/REAndroid/JCommand - compile(files("$rootProject.projectDir/libs/JCommand.jar")) + implementation(files("$rootProject.projectDir/libs/ARSCLib.jar")) + implementation(files("$rootProject.projectDir/libs/smali.jar")) + implementation(files("$rootProject.projectDir/libs/JCommand.jar")) } processResources { filesMatching('apkeditor.properties') { - expand('version': version) + expand('version': project.version) } } javadoc { exclude('com/reandroid/test/**') } + jar { exclude('com/reandroid/test/**') duplicatesStrategy = DuplicatesStrategy.FAIL + manifest { + attributes 'Main-Class': application.mainClass.get() + } } +// Configuración de FatJar compatible con Gradle 8.x task fatJar(type: Jar) { duplicatesStrategy = DuplicatesStrategy.EXCLUDE + archiveClassifier = 'all' + manifest { - attributes( - 'Main-Class': 'com.reandroid.apkeditor.Main' - ) + attributes 'Main-Class': application.mainClass.get() + } + + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } } - from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } } with jar } +task guiFatJar(type: Jar) { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + archiveClassifier = 'gui' + + manifest { + attributes 'Main-Class': 'com.reandroid.apkeditor.gui.GUIApplication' + } + + from sourceSets.main.output + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } +} + diff --git a/docs/.language/README.es.md b/docs/.language/README.es.md new file mode 100644 index 00000000..0b21f249 --- /dev/null +++ b/docs/.language/README.es.md @@ -0,0 +1,91 @@ +

+ Español | English +

+ +

+ APKEditor Futuristic - Interfaz +

+ +## Descripción + +APKEditor Futuristic es una interfaz gráfica moderna que envuelve el motor original de [APKEditor](https://github.com/REAndroid/APKEditor). + +## Objetivo final + +![ui_last](https://github.com/VictorH028/APKEditorFuturistic/blob/master/.img/ui_last.png) + +> [!WARNING] +> En desarrollo. + +## Compilación + +Jar ejecutable de la interfaz gráfica: + +```bash +sh gradlew guiFatJar --offline +``` + +El jar de la GUI se genera en: + +```text +build/libs/APKEditor-1.4.9-gui.jar +``` + +También existe el fat jar general (CLI + GUI): + +```bash +sh gradlew fatJar --offline +``` + +```text +build/libs/APKEditor-1.4.9-all.jar +``` + +> Usa `sh gradlew` si `./gradlew` falla con `permission denied`. + +## Iniciar la interfaz gráfica + +### Opción 1 - Classpath de desarrollo + +```bash +sh gradlew compileJava --offline + +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +### Opción 2 - Jar de la GUI + +```bash +sh gradlew guiFatJar --offline + +java -jar build/libs/APKEditor-1.4.9-gui.jar +``` + +### Termux con VNC + +```bash +cd /data/data/com.termux/files/home/proyecto/APKEditor + +export DISPLAY=:0 +export XAUTHORITY="$HOME/.Xauthority" + +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +Después abre el visor VNC en `localhost:5900` o en el puerto configurado. + +## Uso rápido + +1. Abre la GUI. +2. Pulsa el selector de APK o usa `Ctrl+O`. +3. Elige un archivo APK/ZIP. +4. Explora el árbol, abre archivos en el editor y revisa los logs. +5. Usa `F5` o el botón **Descompilar** para generar el proyecto. +6. Usa las vistas **Construir**, **Fusionar**, **Refactorizar** y **Proteger** para ejecutar las operaciones del motor. +7. Cambia el idioma español/inglés desde la topbar o desde **Configuración**. + +## Estado + +La interfaz actual está implementada con Swing + Java2D. Incluye temas en caliente y cambio de idioma español/inglés. El detalle de módulos, atajos, temas, idioma, operaciones y limitaciones está en [docs/apkeditor-ui.md](docs/apkeditor-ui.md). diff --git a/docs/.language/dev-ui.es.md b/docs/.language/dev-ui.es.md new file mode 100644 index 00000000..e2f30c5b --- /dev/null +++ b/docs/.language/dev-ui.es.md @@ -0,0 +1,539 @@ +# APKEDITOR UI + +

+ Español | English +

+ +Documentación de la interfaz gráfica de APKEditor. + +> Estado actual: interfaz implementada en **Swing + Java2D**. + +## 1. Resumen + +APKEDITOR UI es una interfaz de escritorio para: + +- Abrir y analizar archivos APK/ZIP. +- Explorar el árbol de archivos del paquete. +- Leer código Smali, XML y recursos. +- Ver imágenes binarias como volcado hexadecimal. +- Ejecutar las operaciones existentes del motor: decodificar, construir, fusionar, refactorizar y proteger. +- Consultar logs, progreso y estadísticas del APK. + +La interfaz no reimplementa el motor. Las vistas sólo preparan los datos y `OperationService` ejecuta las operaciones registradas en `OperationCatalog`. + +## 2. Requisitos + +- **JDK 21**. +- Gradle Wrapper incluido en el repositorio (`gradlew`). +- Los archivos de `libs/`: + - `ARSCLib.jar` + - `smali.jar` + - `JCommand.jar` +- Un servidor gráfico X11: + - En escritorio Linux: Xorg/Wayland con XWayland. + - En Termux: VNC (`Xvnc`) o `Termux:X11`. +- Para VNC, un visor VNC conectado al puerto correspondiente. + +No hace falta instalar Android SDK para ejecutar la interfaz. + +## 3. Compilación + +Desde la raíz del proyecto: + +```bash +sh gradlew compileJava --offline +``` + +Compilación completa sin pruebas: + +```bash +sh gradlew build -x test --offline +``` + +Jar ejecutable de la GUI con dependencias: + +```bash +sh gradlew guiFatJar --offline +``` + +Fat jar general (CLI + GUI): + +```bash +sh gradlew fatJar --offline +``` + +Artefactos generados: + +```text +build/classes/java/main/ clases compiladas +build/resources/main/ recursos +build/libs/APKEditor-1.4.9.jar jar normal +build/libs/APKEditor-1.4.9-all.jar fat jar general +build/libs/APKEditor-1.4.9-gui.jar jar ejecutable de la GUI +``` + +> Usa `sh gradlew` si `./gradlew` falla con `permission denied`. + +## 4. Ejecución + +### 4.1 Termux con VNC (entorno probado) + +Si ya existe un servidor VNC en `:0`: + +```bash +cd /data/data/com.termux/files/home/proyecto/APKEditor + +export DISPLAY=:0 +export XAUTHORITY="$HOME/.Xauthority" + +sh gradlew compileJava --offline + +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +Después abre el visor VNC en `localhost:5900` (o el puerto configurado). + +Si el servidor no está iniciado, un ejemplo con TigerVNC es: + +```bash +vncserver :0 -geometry 2400x1080 -depth 24 +``` + +### 4.2 Termux con Termux:X11 + +```bash +export DISPLAY=:0 +termux-x11 :0 & +``` + +Abre la aplicación Termux:X11 y ejecuta la GUI con el mismo comando Java. + +### 4.3 Escritorio Linux + +```bash +sh gradlew compileJava --offline + +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +### 4.4 Desde el jar de la GUI + +```bash +sh gradlew guiFatJar --offline + +java -jar build/libs/APKEditor-1.4.9-gui.jar +``` + +El fat jar general también contiene la GUI, pero su entrada por defecto es el CLI: + +```bash +sh gradlew fatJar --offline + +java -cp build/libs/APKEditor-1.4.9-all.jar \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +> Importante: `sh gradlew run` ejecuta el **CLI** (`com.reandroid.apkeditor.Main`), no la GUI. Usa `guiFatJar` y `java -jar build/libs/APKEditor-1.4.9-gui.jar` para abrir la interfaz. + +## 5. Estructura de la ventana + +La ventana principal es `MainWindow`: + +```text ++------------------------------------------------------------------+ +| Topbar: marca | selector de APK | tema | idioma | minimizar/maximizar/cerrar | ++------------+-----------------------------------------------------+ +| Sidebar | Área de contenido | +| 236 px | Panel / Workspace / Operaciones / Información | +| | Configuración | ++------------+-----------------------------------------------------+ +| Barra de estado: sistema, memoria y estado de la operación | ++------------------------------------------------------------------+ +``` + +- Tamaño mínimo: `1200 x 800`. +- Tamaño inicial: hasta `1536 x 1024`, ajustado al tamaño de pantalla. +- Topbar: `74 px`. +- Sidebar: `236 px`. +- Barra de estado: `48 px`. +- La ventana no usa decoración del sistema; se arrastra desde la topbar y se maximiza con doble clic o con el botón correspondiente. + +## 6. Navegación + +| Elemento del sidebar | Ruta | Destino | +| --- | --- | --- | +| Panel | `DASHBOARD` | Panel de control, estado y accesos rápidos | +| Descompilar | `DECOMPILE` | Workspace en la pestaña Estructura | +| Analizar | `ANALYZE` | Workspace en la pestaña Análisis | +| Visor Smali | `SMALI_VIEWER` | Workspace en la pestaña Código | +| Construir | `BUILD` | Vista de operación Build | +| Fusionar | `MERGE` | Vista de operación Merge | +| Refactorizar | `REFACTOR` | Vista de operación Refactor | +| Proteger | `PROTECT` | Vista de operación Protect | +| Información | `INFORMATION` | Detalle técnico del APK | +| Configuración | `SETTINGS` | Temas, idioma, rutas, motor y atajos | + +La ruta `DECODE` existe como alias y se redirige a `DECOMPILE`. La ruta `LOGS` existe en el enum, pero los logs se muestran integrados en el workspace y en las vistas de operación. + +## 7. Workspace + +El workspace (`DecompileView`) es el área principal de trabajo. + +### 7.1 Encabezado + +- Título y subtítulo con el APK activo. +- Botón de búsqueda de archivos. +- Botón para restablecer el espacio de trabajo. +- Botón **Descompilar**. +- Pestañas de trabajo: **Estructura**, **Código**, **Manifest**, **Recursos** y **Análisis**. + +Las pestañas son vistas del mismo workspace, no rutas independientes: + +- **Estructura**: muestra el árbol y enfoca el explorador. +- **Código**: abre el editor y enfoca el documento activo. +- **Manifest**: abre `AndroidManifest.xml`. +- **Recursos**: intenta abrir `strings.xml`; si no existe, abre `resources.arsc`. +- **Análisis**: genera y abre un reporte de texto con los datos de `ApkAnalysis`. + +### 7.2 Paneles + +El workspace usa `JSplitPane` anidados: + +```text ++----------------+---------------------------+------------------+ +| Explorador | Editor de código | Panel de log | +| Árbol APK | Pestañas de documentos | Eventos | ++----------------+---------------------------+------------------+ +| Dock inferior: progreso | información | estadísticas | ++---------------------------------------------------------------+ +``` + +- El ancho de los divisores es de `14 px`. +- Los paneles conservan proporciones y mínimos al redimensionar: + - Explorador: mínimo `240 px`. + - Editor: mínimo `360 px`. + - Log: mínimo `260 px`. +- El dock inferior mide `175 px`. + +### 7.3 Explorador de archivos + +- Árbol con iconos por tipo: Smali, XML, DEX, recursos, imágenes, librerías, manifest, assets, carpetas y archivos. +- Búsqueda incremental por nombre. +- Expansión con un solo clic. +- Al seleccionar un archivo se abre en el editor. +- Se conservan las rutas expandidas al filtrar. +- El árbol se limita a 6000 entradas para mantener la interfaz fluida. + +### 7.4 Editor + +El editor (`CodeEditor` + `EditorTabs`) incluye: + +- Una pestaña por archivo abierto. +- Numeración de líneas. +- Resaltado de línea actual. +- Resaltado de sintaxis para Smali, XML, JSON, Java y texto. +- Estado con `Ln`, `Col`, espacios, codificación y lenguaje. +- Búsqueda dentro del archivo. +- Ir a línea. +- Copiar selección. +- Cierre de pestañas desde la pestaña o desde el menú del editor. +- Vista hexadecimal para archivos binarios. + +Límites de lectura: + +- Lectura de archivos desde ZIP: hasta 2 MB por entrada. +- Volcado hexadecimal visible: primeros 4096 bytes. + +> En esta versión los cambios del editor viven en memoria. Todavía no se escriben de vuelta al APK ni al proyecto descompilado. + +## 8. Carga y análisis de APK + +Desde la topbar: + +1. Pulsa el selector de APK o usa `Ctrl+O`. +2. Elige un archivo `.apk`, `.zip` o `.aab`. El selector permite los tres formatos; el soporte real de AAB depende de las capacidades del motor. +3. `ApkService.analyze()` se ejecuta en segundo plano. +4. Se actualizan: + - `AppState.currentInput` + - `AppState.analysis` + - `AppState.currentTree` +5. Se muestran los mensajes del análisis en el panel de log. +6. La interfaz navega al workspace. + +`ApkService` puede analizar APKs, ZIPs y directorios descompilados. La lectura del manifest y el conteo de clases usan ARSCLib y el lector DEX del motor. + +## 9. Operaciones del motor + +Las operaciones se registran en `OperationCatalog` y se ejecutan a través de `OperationService`. + +| Operación | ID | Entrada | Salida | +| --- | --- | --- | --- | +| Descompilar | `decode` | APK | Directorio de proyecto | +| Build | `build` | Directorio descompilado | APK | +| Merge | `merge` | APK | APK fusionado | +| Refactor | `refactor` | APK | APK refactorizado | +| Protect | `protect` | APK | APK protegido | +| Info | `info` | APK | Información del paquete | + +`Info` está registrada en el catálogo para el motor; la vista **Información** del sidebar muestra los datos obtenidos durante el análisis del APK. + +En la interfaz, Build/Merge/Refactor/Protect comparten `OperationView`. Cada vista permite: + +- Escribir o elegir la entrada. +- Escribir o elegir la salida. +- Ejecutar la operación. +- Restablecer el formulario. +- Ver progreso, log y estado al finalizar. + +La descompilación se inicia desde el workspace: + +- Si no hay APK seleccionado, se abre el selector. +- La salida se genera automáticamente como `_decompile`, añadiendo un índice si ya existe. +- Al terminar correctamente, la ruta de salida se guarda en `AppState.currentOutput`. + +`OperationService` ejecuta el motor en un hilo secundario y publica los cambios en el EDT. La UI nunca llama directamente a `Decompiler`, `Builder`, `Protector`, etc. + +## 10. Atajos de teclado + +Atajos implementados: + +| Atajo | Acción | +| --- | --- | +| `F5` | Ir al workspace e iniciar descompilación | +| `Ctrl+O` | Seleccionar APK | +| `Ctrl+1` | Volver al workspace | +| `Ctrl+,` | Abrir Configuración | +| `Ctrl+F` | Buscar en el archivo activo | +| `Ctrl+G` | Ir a una línea del editor | +| `Esc` | Cerrar la búsqueda del editor | + +La tarjeta de Configuración también muestra `Ctrl+S` y `Ctrl+W`; en esta versión aparecen como referencia, pero todavía no están enlazados a acciones. + +## 11. Temas y estilos + +Temas disponibles: + +| Tema | Característica | +| --- | --- | +| `Futuristic` | Tema por defecto, azul neón sobre fondo oscuro | +| `Cyberpunk` | Morado/rosa neón | +| `Dark` | Gris azulado sobrio | +| `Midnight` | Azul profundo | + +Se pueden cambiar desde: + +- El selector de tema de la topbar. +- La tarjeta **Apariencia** de Configuración. + +Al cambiar el tema, `MainWindow` reconstruye la interfaz y conserva los archivos abiertos mediante `AppState`. El tema no se guarda entre reinicios. + +### 11.1 Idioma + +La interfaz admite español e inglés: + +- Idioma por defecto: español. +- Selector en la topbar, junto al tema. +- Selector en la tarjeta **Apariencia** de Configuración. +- Al cambiar el idioma, `MainWindow` reconstruye la interfaz y conserva la ruta actual y los archivos abiertos mediante `AppState`. +- Las traducciones viven en `src/main/resources/i18n/messages.properties` (inglés base) y `messages_es.properties` (español). +- `I18n` centraliza las traducciones y devuelve la clave si falta un texto. +- El idioma no se guarda entre reinicios. + +Paleta principal de `Futuristic`: + +| Uso | Color | +| --- | --- | +| Fondo | `#020B18` | +| Fondo secundario | `#041426` | +| Superficie | `#06182A` | +| Borde | `#123653` | +| Primario | `#00BFFF` | +| Primario brillante | `#00D5FF` | +| Éxito | `#00E6A0` | +| Advertencia | `#FFB547` | +| Error | `#FF4668` | +| Texto | `#E6F4FF` | +| Texto secundario | `#9CB4CC` | +| Texto atenuado | `#64829F` | + +Tipografías: + +- Inter, Segoe UI o SansSerif. +- JetBrains Mono, Cascadia Code, Fira Code, Source Code Pro, Consolas o Monospaced. + +Los iconos se dibujan con Java2D mediante `IconFactory`; no se usan imágenes estáticas para los componentes. + +## 12. Arquitectura + +### 12.1 Paquetes + +| Paquete | Responsabilidad | +| --- | --- | +| `gui` | `GUIApplication` y `MainWindow` | +| `gui.view` | Dashboard, workspace, operaciones, información y configuración | +| `gui.panel` | Paneles reutilizables de progreso, información, estadísticas y estado | +| `gui.components` | Botones, tarjetas, campos, barras y controles visuales | +| `gui.editor` | Editor, pestañas, numeración y resaltado Smali | +| `gui.tree` | Explorador de archivos | +| `gui.log` | Panel de logs | +| `gui.model` | Estado compartido y modelos de la UI | +| `gui.service` | Análisis APK, logs y ejecución de operaciones | +| `gui.i18n` | Traducciones español/inglés y cambio de idioma | +| `gui.theme` | Colores, fuentes, iconos y temas | +| `gui.navigation` | Rutas y navegación | +| `core` | Contratos y adaptadores del motor | +| `operations` | Registro central de operaciones existentes | + +### 12.2 Flujo de datos + +```text +Usuario + | + v +Vista / componente + | + +--> AppState (estado compartido) + | + +--> OperationService + | + v + OperationRegistry + | + v + LegacyOptionsOperation + | + v + Motor APKEditor + | + +--> OperationListener + +--> ExecutionContext + +--> LogService + | + v + LogPanel +``` + +### 12.3 Reglas de diseño + +- Las vistas no crean ni ejecutan operaciones del motor directamente. +- `AppState` no contiene lógica de descompilación. +- `OperationService` encapsula el trabajo en segundo plano. +- `LogService` es el bus de mensajes de la interfaz. +- Las actualizaciones de UI se publican en el EDT con `SwingUtilities.invokeLater`. +- Los listeners se eliminan en `removeNotify()` para evitar fugas. + +## 13. Logs y diagnóstico + +`LogService` es un singleton con: + +- Niveles: `INFO`, `SUCCESS`, `WARNING`, `ERROR`, `COMMAND`, `DEBUG`. +- Timestamps. +- Autoscroll. +- Limpieza del panel. +- Exportación a archivo de texto. + +El panel de log aparece: + +- En el workspace. +- En cada vista de operación. + +Para diagnosticar un fallo: + +1. Revisa el panel de log. +2. Exporta el log con el botón **Exportar**. +3. Verifica la ruta de entrada y salida. +4. Comprueba que el APK/proyecto existe y es legible. +5. Si el error viene del motor, revisa el mensaje completo en el log. + +## 14. Solución de problemas + +### `No X11 DISPLAY variable was set` + +No hay servidor gráfico o falta exportar el display: + +```bash +export DISPLAY=:0 +``` + +Verifica que el servidor esté activo: + +```bash +pgrep -af Xvnc +``` + +### La GUI no aparece en Termux + +- Confirma que el visor VNC está conectado. +- Comprueba `echo $DISPLAY`. +- Prueba `xrandr -display :0` para verificar que el servidor responde. +- Revisa la salida de Java en la terminal. + +### `permission denied: ./gradlew` + +Usa: + +```bash +sh gradlew compileJava --offline +``` + +### `Could not find or load main class ...GUIApplication` + +- Compila primero con `sh gradlew compileJava --offline`. +- Ejecuta desde la raíz del proyecto. +- Mantén las comillas en el classpath: + +```bash +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +### `sh gradlew run` abre la consola + +Es el comportamiento esperado: el `mainClass` del proyecto es el CLI. Usa el comando de GUI indicado en este documento. + +### El APK no se puede analizar + +- Verifica que el archivo exista y sea legible. +- Revisa el log para ver si falló el manifest, el DEX o el ZIP. +- Algunos APKs protegidos o corruptos pueden fallar en el motor. +- El árbol se limita a 6000 entradas y la lectura de entradas ZIP a 2 MB. + +### La ventana es demasiado pequeña + +La ventana tiene un mínimo de `1200 x 800`. Si la pantalla es menor, aumenta la resolución del servidor X/VNC o usa una pantalla externa. + +## 15. Estado y limitaciones actuales + +Implementado: + +- Ventana principal, topbar, sidebar, rutas y barra de estado. +- Dashboard, workspace, información y configuración. +- Temas en caliente. +- Cambio de idioma español/inglés en caliente. +- Carga y análisis de APK. +- Árbol de archivos y búsqueda. +- Editor con resaltado, numeración, búsqueda e ir a línea. +- Descompilación y operaciones Build/Merge/Refactor/Protect. +- Logs con exportación. +- Progreso simulado mientras el motor reporta avance. + +Pendiente o limitado: + +- Guardar los cambios del editor en el APK o proyecto. +- Atajos `Ctrl+S` y `Ctrl+W`. +- Persistencia del tema, del idioma y de la sesión entre reinicios. +- Formularios avanzados con todas las opciones específicas del motor. +- Ruta dedicada de logs en el sidebar. +- Pruebas automatizadas de la interfaz. + +## 16. Referencias + +- [README del proyecto](../README.md) +- [Referencia visual](../.img/ui_last.png) +- [Repositorio APKEditor](https://github.com/REAndroid/APKEditor) +- `build.gradle`: dependencias, versión y tareas `fatJar` / `guiFatJar` +- `src/main/java/com/reandroid/apkeditor/gui/`: implementación de la interfaz +- `src/main/java/com/reandroid/apkeditor/core/`: contratos del motor +- `src/main/java/com/reandroid/apkeditor/operations/OperationCatalog.java`: registro de operaciones diff --git a/docs/dev-ui.md b/docs/dev-ui.md new file mode 100644 index 00000000..84d0390b --- /dev/null +++ b/docs/dev-ui.md @@ -0,0 +1,539 @@ +# APKEDITOR UI + +

+ Español | English +

+ +Documentation for the APKEditor graphical interface. + +> Current status: the interface is implemented with **Swing + Java2D**. + +## 1. Overview + +APKEDITOR UI is a desktop interface for: + +- Opening and analyzing APK/ZIP files. +- Browsing the package file tree. +- Reading Smali code, XML, and resources. +- Viewing binary images as hexadecimal dumps. +- Running the existing engine operations: decode, build, merge, refactor, and protect. +- Checking logs, progress, and APK statistics. + +The interface does not reimplement the engine. Views only prepare the data, and `OperationService` runs the operations registered in `OperationCatalog`. + +## 2. Requirements + +- **JDK 21**. +- Gradle Wrapper included in the repository (`gradlew`). +- The files in `libs/`: + - `ARSCLib.jar` + - `smali.jar` + - `JCommand.jar` +- An X11 graphical server: + - On a Linux desktop: Xorg/Wayland with XWayland. + - On Termux: VNC (`Xvnc`) or `Termux:X11`. +- For VNC, a VNC viewer connected to the corresponding port. + +The Android SDK is not required to run the interface. + +## 3. Build + +From the project root: + +```bash +sh gradlew compileJava --offline +``` + +Full build without tests: + +```bash +sh gradlew build -x test --offline +``` + +Executable GUI jar with dependencies: + +```bash +sh gradlew guiFatJar --offline +``` + +General fat jar (CLI + GUI): + +```bash +sh gradlew fatJar --offline +``` + +Generated artifacts: + +```text +build/classes/java/main/ compiled classes +build/resources/main/ resources +build/libs/APKEditor-1.4.9.jar regular jar +build/libs/APKEditor-1.4.9-all.jar general fat jar +build/libs/APKEditor-1.4.9-gui.jar executable GUI jar +``` + +> Use `sh gradlew` if `./gradlew` fails with `permission denied`. + +## 4. Running + +### 4.1 Termux with VNC (tested environment) + +If a VNC server already exists on `:0`: + +```bash +cd /data/data/com.termux/files/home/proyecto/APKEditor + +export DISPLAY=:0 +export XAUTHORITY="$HOME/.Xauthority" + +sh gradlew compileJava --offline + +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +Then open the VNC viewer at `localhost:5900` (or the configured port). + +If the server is not running, an example using TigerVNC is: + +```bash +vncserver :0 -geometry 2400x1080 -depth 24 +``` + +### 4.2 Termux with Termux:X11 + +```bash +export DISPLAY=:0 +termux-x11 :0 & +``` + +Open the Termux:X11 application and run the GUI with the same Java command. + +### 4.3 Linux desktop + +```bash +sh gradlew compileJava --offline + +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +### 4.4 From the GUI jar + +```bash +sh gradlew guiFatJar --offline + +java -jar build/libs/APKEditor-1.4.9-gui.jar +``` + +The general fat jar also contains the GUI, but its default entry point is the CLI: + +```bash +sh gradlew fatJar --offline + +java -cp build/libs/APKEditor-1.4.9-all.jar \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +> Important: `sh gradlew run` runs the **CLI** (`com.reandroid.apkeditor.Main`), not the GUI. Use `guiFatJar` and `java -jar build/libs/APKEditor-1.4.9-gui.jar` to open the interface. + +## 5. Window structure + +The main window is `MainWindow`: + +```text ++------------------------------------------------------------------+ +| Top bar: brand | APK selector | theme | language | minimize/maximize/close | ++------------+-----------------------------------------------------+ +| Sidebar | Content area | +| 236 px | Dashboard / Workspace / Operations / Information | +| | Settings | ++------------+-----------------------------------------------------+ +| Status bar: system, memory, and operation status | ++------------------------------------------------------------------+ +``` + +- Minimum size: `1200 x 800`. +- Initial size: up to `1536 x 1024`, adjusted to the screen size. +- Top bar: `74 px`. +- Sidebar: `236 px`. +- Status bar: `48 px`. +- The window does not use system decoration; it is dragged from the top bar and maximized with a double click or the corresponding button. + +## 6. Navigation + +| Sidebar item | Route | Destination | +| --- | --- | --- | +| Dashboard | `DASHBOARD` | Dashboard, status, and quick actions | +| Decompile | `DECOMPILE` | Workspace on the Structure tab | +| Analyze | `ANALYZE` | Workspace on the Analysis tab | +| Smali Viewer | `SMALI_VIEWER` | Workspace on the Code tab | +| Build | `BUILD` | Build operation view | +| Merge | `MERGE` | Merge operation view | +| Refactor | `REFACTOR` | Refactor operation view | +| Protect | `PROTECT` | Protect operation view | +| Information | `INFORMATION` | Technical APK details | +| Settings | `SETTINGS` | Themes, language, paths, engine, and shortcuts | + +The `DECODE` route exists as an alias and redirects to `DECOMPILE`. The `LOGS` route exists in the enum, but logs are shown integrated into the workspace and operation views. + +## 7. Workspace + +The workspace (`DecompileView`) is the main working area. + +### 7.1 Header + +- Title and subtitle with the active APK. +- File search button. +- Button to reset the workspace. +- **Decompile** button. +- Work tabs: **Structure**, **Code**, **Manifest**, **Resources**, and **Analysis**. + +The tabs are views of the same workspace, not independent routes: + +- **Structure**: shows the tree and focuses the explorer. +- **Code**: opens the editor and focuses the active document. +- **Manifest**: opens `AndroidManifest.xml`. +- **Resources**: tries to open `strings.xml`; if it does not exist, opens `resources.arsc`. +- **Analysis**: generates and opens a text report with the `ApkAnalysis` data. + +### 7.2 Panels + +The workspace uses nested `JSplitPane` components: + +```text ++----------------+---------------------------+------------------+ +| Explorer | Code editor | Log panel | +| APK tree | Document tabs | Events | ++----------------+---------------------------+------------------+ +| Bottom dock: progress | information | statistics | ++---------------------------------------------------------------+ +``` + +- The divider width is `14 px`. +- Panels keep their proportions and minimum sizes when resized: + - Explorer: minimum `240 px`. + - Editor: minimum `360 px`. + - Log: minimum `260 px`. +- The bottom dock is `175 px` high. + +### 7.3 File explorer + +- Tree with icons by type: Smali, XML, DEX, resources, images, libraries, manifest, assets, folders, and files. +- Incremental search by name. +- Single-click expansion. +- Selecting a file opens it in the editor. +- Expanded paths are preserved when filtering. +- The tree is limited to 6000 entries to keep the interface responsive. + +### 7.4 Editor + +The editor (`CodeEditor` + `EditorTabs`) includes: + +- One tab per open file. +- Line numbers. +- Current line highlighting. +- Syntax highlighting for Smali, XML, JSON, Java, and text. +- Status with `Ln`, `Col`, spaces, encoding, and language. +- Search within the file. +- Go to line. +- Copy selection. +- Closing tabs from the tab or from the editor menu. +- Hexadecimal view for binary files. + +Read limits: + +- Reading files from ZIP: up to 2 MB per entry. +- Visible hexadecimal dump: first 4096 bytes. + +> In this version, editor changes live in memory. They are not yet written back to the APK or the decompiled project. + +## 8. APK loading and analysis + +From the top bar: + +1. Click the APK selector or press `Ctrl+O`. +2. Choose an `.apk`, `.zip`, or `.aab` file. The selector allows all three formats; actual AAB support depends on the engine capabilities. +3. `ApkService.analyze()` runs in the background. +4. The following are updated: + - `AppState.currentInput` + - `AppState.analysis` + - `AppState.currentTree` +5. Analysis messages are shown in the log panel. +6. The interface navigates to the workspace. + +`ApkService` can analyze APKs, ZIPs, and decompiled directories. Manifest reading and class counting use ARSCLib and the engine's DEX reader. + +## 9. Engine operations + +Operations are registered in `OperationCatalog` and executed through `OperationService`. + +| Operation | ID | Input | Output | +| --- | --- | --- | --- | +| Decompile | `decode` | APK | Project directory | +| Build | `build` | Decompiled directory | APK | +| Merge | `merge` | APK | Merged APK | +| Refactor | `refactor` | APK | Refactored APK | +| Protect | `protect` | APK | Protected APK | +| Info | `info` | APK | Package information | + +`Info` is registered in the catalog for the engine; the sidebar **Information** view shows the data obtained during APK analysis. + +In the interface, Build/Merge/Refactor/Protect share `OperationView`. Each view allows you to: + +- Type or choose the input. +- Type or choose the output. +- Run the operation. +- Reset the form. +- View progress, log, and final status. + +Decompilation is started from the workspace: + +- If no APK is selected, the selector opens. +- The output is generated automatically as `_decompile`, adding an index if it already exists. +- On success, the output path is stored in `AppState.currentOutput`. + +`OperationService` runs the engine on a background thread and publishes changes to the EDT. The UI never calls `Decompiler`, `Builder`, `Protector`, etc. directly. + +## 10. Keyboard shortcuts + +Implemented shortcuts: + +| Shortcut | Action | +| --- | --- | +| `F5` | Go to the workspace and start decompilation | +| `Ctrl+O` | Select APK | +| `Ctrl+1` | Return to the workspace | +| `Ctrl+,` | Open Settings | +| `Ctrl+F` | Search in the active file | +| `Ctrl+G` | Go to a line in the editor | +| `Esc` | Close the editor search | + +The Settings card also shows `Ctrl+S` and `Ctrl+W`; in this version they are shown as a reference, but they are not yet bound to actions. + +## 11. Themes and styles + +Available themes: + +| Theme | Characteristic | +| --- | --- | +| `Futuristic` | Default theme, neon blue on a dark background | +| `Cyberpunk` | Neon purple/pink | +| `Dark` | Sober blue-gray | +| `Midnight` | Deep blue | + +They can be changed from: + +- The top bar theme selector. +- The **Appearance** card in Settings. + +When the theme changes, `MainWindow` rebuilds the interface and preserves open files through `AppState`. The theme is not saved between restarts. + +### 11.1 Language + +The interface supports Spanish and English: + +- Default language: Spanish. +- Selector in the top bar, next to the theme. +- Selector in the **Appearance** card in Settings. +- When the language changes, `MainWindow` rebuilds the interface and preserves the current route and open files through `AppState`. +- Translations live in `src/main/resources/i18n/messages.properties` (English base) and `messages_es.properties` (Spanish). +- `I18n` centralizes translations and returns the key if a text is missing. +- The language is not saved between restarts. + +Main `Futuristic` palette: + +| Use | Color | +| --- | --- | +| Background | `#020B18` | +| Secondary background | `#041426` | +| Surface | `#06182A` | +| Border | `#123653` | +| Primary | `#00BFFF` | +| Bright primary | `#00D5FF` | +| Success | `#00E6A0` | +| Warning | `#FFB547` | +| Error | `#FF4668` | +| Text | `#E6F4FF` | +| Secondary text | `#9CB4CC` | +| Muted text | `#64829F` | + +Fonts: + +- Inter, Segoe UI, or SansSerif. +- JetBrains Mono, Cascadia Code, Fira Code, Source Code Pro, Consolas, or Monospaced. + +Icons are drawn with Java2D through `IconFactory`; static images are not used for components. + +## 12. Architecture + +### 12.1 Packages + +| Package | Responsibility | +| --- | --- | +| `gui` | `GUIApplication` and `MainWindow` | +| `gui.view` | Dashboard, workspace, operations, information, and settings | +| `gui.panel` | Reusable progress, information, statistics, and status panels | +| `gui.components` | Buttons, cards, fields, bars, and visual controls | +| `gui.editor` | Editor, tabs, line numbers, and Smali highlighting | +| `gui.tree` | File explorer | +| `gui.log` | Log panel | +| `gui.model` | Shared state and UI models | +| `gui.service` | APK analysis, logs, and operation execution | +| `gui.i18n` | Spanish/English translations and language switching | +| `gui.theme` | Colors, fonts, icons, and themes | +| `gui.navigation` | Routes and navigation | +| `core` | Engine contracts and adapters | +| `operations` | Central registry of existing operations | + +### 12.2 Data flow + +```text +User + | + v +View / component + | + +--> AppState (shared state) + | + +--> OperationService + | + v + OperationRegistry + | + v + LegacyOptionsOperation + | + v + APKEditor engine + | + +--> OperationListener + +--> ExecutionContext + +--> LogService + | + v + LogPanel +``` + +### 12.3 Design rules + +- Views do not create or run engine operations directly. +- `AppState` does not contain decompilation logic. +- `OperationService` encapsulates background work. +- `LogService` is the interface message bus. +- UI updates are published to the EDT with `SwingUtilities.invokeLater`. +- Listeners are removed in `removeNotify()` to avoid leaks. + +## 13. Logs and diagnostics + +`LogService` is a singleton with: + +- Levels: `INFO`, `SUCCESS`, `WARNING`, `ERROR`, `COMMAND`, `DEBUG`. +- Timestamps. +- Autoscroll. +- Panel clearing. +- Export to a text file. + +The log panel appears: + +- In the workspace. +- In each operation view. + +To diagnose a failure: + +1. Check the log panel. +2. Export the log with the **Export** button. +3. Verify the input and output paths. +4. Make sure the APK/project exists and is readable. +5. If the error comes from the engine, check the full message in the log. + +## 14. Troubleshooting + +### `No X11 DISPLAY variable was set` + +There is no graphical server or the display is not exported: + +```bash +export DISPLAY=:0 +``` + +Verify that the server is running: + +```bash +pgrep -af Xvnc +``` + +### The GUI does not appear on Termux + +- Confirm that the VNC viewer is connected. +- Check `echo $DISPLAY`. +- Try `xrandr -display :0` to verify that the server responds. +- Check the Java output in the terminal. + +### `permission denied: ./gradlew` + +Use: + +```bash +sh gradlew compileJava --offline +``` + +### `Could not find or load main class ...GUIApplication` + +- Compile first with `sh gradlew compileJava --offline`. +- Run from the project root. +- Keep the quotes in the classpath: + +```bash +java -cp 'build/classes/java/main:build/resources/main:libs/*' \ + com.reandroid.apkeditor.gui.GUIApplication +``` + +### `sh gradlew run` opens the console + +This is expected behavior: the project `mainClass` is the CLI. Use the GUI command described in this document. + +### The APK cannot be analyzed + +- Verify that the file exists and is readable. +- Check the log to see whether the manifest, DEX, or ZIP failed. +- Some protected or corrupt APKs may fail in the engine. +- The tree is limited to 6000 entries and ZIP entry reading to 2 MB. + +### The window is too small + +The window has a minimum size of `1200 x 800`. If the screen is smaller, increase the X/VNC server resolution or use an external display. + +## 15. Current status and limitations + +Implemented: + +- Main window, top bar, sidebar, routes, and status bar. +- Dashboard, workspace, information, and settings. +- Live theme switching. +- Live Spanish/English language switching. +- APK loading and analysis. +- File tree and search. +- Editor with highlighting, line numbers, search, and go-to-line. +- Decompilation and Build/Merge/Refactor/Protect operations. +- Logs with export. +- Simulated progress while the engine reports progress. + +Pending or limited: + +- Saving editor changes to the APK or project. +- `Ctrl+S` and `Ctrl+W` shortcuts. +- Persistence of theme, language, and session between restarts. +- Advanced forms with all engine-specific options. +- Dedicated logs route in the sidebar. +- Automated interface tests. + +## 16. References + +- [Project README](../README.en.md) +- [Visual reference](../.img/ui_last.png) +- [APKEditor repository](https://github.com/REAndroid/APKEditor) +- `build.gradle`: dependencies, version, and `fatJar` / `guiFatJar` tasks +- `src/main/java/com/reandroid/apkeditor/gui/`: interface implementation +- `src/main/java/com/reandroid/apkeditor/core/`: engine contracts +- `src/main/java/com/reandroid/apkeditor/operations/OperationCatalog.java`: operation registry diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar old mode 100755 new mode 100644 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties old mode 100755 new mode 100644 index 8a91a6b6..f5da588c --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https://services.gradle.org/distributions/gradle-6.5.1-all.zip +distributionUrl=https://services.gradle.org/distributions/gradle-8.5-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat old mode 100755 new mode 100644 diff --git a/libs/ARSCLib.jar b/libs/ARSCLib.jar index 1346ed6a..babcc09a 100644 Binary files a/libs/ARSCLib.jar and b/libs/ARSCLib.jar differ diff --git a/settings.gradle b/settings.gradle old mode 100755 new mode 100644 diff --git a/src/main/java/com/reandroid/apkeditor/CommandExecutor.java b/src/main/java/com/reandroid/apkeditor/CommandExecutor.java index 8b730372..2eed677a 100644 --- a/src/main/java/com/reandroid/apkeditor/CommandExecutor.java +++ b/src/main/java/com/reandroid/apkeditor/CommandExecutor.java @@ -15,6 +15,8 @@ */ package com.reandroid.apkeditor; +import com.reandroid.apkeditor.core.ExecutionContext; + import com.reandroid.apk.APKLogger; import com.reandroid.archive.ZipEntryMap; import com.reandroid.apk.ApkModule; @@ -97,6 +99,7 @@ public void logMessage(String msg) { if(!mEnableLog){ return; } + ExecutionContext.message(mLogTag + msg); Logger.i(mLogTag + msg); } @Override @@ -104,6 +107,7 @@ public void logError(String msg, Throwable tr) { if(!mEnableLog){ return; } + ExecutionContext.error(mLogTag + msg, tr); Logger.e(mLogTag + msg, tr); } @Override @@ -111,6 +115,7 @@ public void logVerbose(String msg) { if(!mEnableLog){ return; } + ExecutionContext.message(mLogTag + msg); Logger.sameLine(mLogTag + msg); } @Override @@ -146,4 +151,3 @@ protected static void removeSignature(ApkModule module){ archive.remove("stamp-cert-sha256"); } } - diff --git a/src/main/java/com/reandroid/apkeditor/common/AndroidManifestHelper.java b/src/main/java/com/reandroid/apkeditor/common/AndroidManifestHelper.java index d8124fea..63a32ee3 100644 --- a/src/main/java/com/reandroid/apkeditor/common/AndroidManifestHelper.java +++ b/src/main/java/com/reandroid/apkeditor/common/AndroidManifestHelper.java @@ -21,7 +21,7 @@ import com.reandroid.arsc.chunk.xml.AndroidManifestBlock; import com.reandroid.arsc.chunk.xml.ResXmlAttribute; import com.reandroid.arsc.chunk.xml.ResXmlElement; -import com.reandroid.arsc.value.ValueType; +import com.reandroid.arsc.value.DataType; import com.reandroid.utils.HexUtil; import com.reandroid.utils.collection.CollectionUtil; import com.reandroid.utils.collection.EmptyList; @@ -44,14 +44,14 @@ public static List listSplitRequired(ResXmlElement parentElement) if(nameAttribute == null){ return false; } - String value = nameAttribute.getValueAsString(); + String value = nameAttribute.getDataAsString(); if(value == null){ return false; } if (value.equals("com.android.dynamic.apk.fused.modules")){ ResXmlAttribute attribute = element.searchAttributeByResourceId(AndroidManifest.ID_value); if (attribute != null){ - String attributeValue = attribute.getValueAsString(); + String attributeValue = attribute.getDataAsString(); if (attributeValue != null && attributeValue.equals(ApkUtil.DEF_MODULE_NAME)) { return true; } @@ -71,8 +71,9 @@ public static void removeAttributeFromManifestByName(AndroidManifestBlock androi } return; } + // Antes hera un valor entero boolean removed = manifestElement.removeAttributesWithName(resourceName); - if (removed && logger != null) { + if (removed ) { logger.logMessage("Removed-attribute : " + resourceName); } } @@ -86,7 +87,7 @@ public static void removeAttributeFromManifestById(AndroidManifestBlock androidM return; } boolean removed = manifestElement.removeAttributesWithId(resourceId); - if (removed && logger != null) { + if (removed ) { logger.logMessage("Removed-attribute : " + HexUtil.toHex8("@0x", resourceId)); } } @@ -108,7 +109,7 @@ public static void removeAttributeFromManifestAndApplication(AndroidManifestBloc ResXmlElement applicationElement = manifestElement.getElement( AndroidManifest.TAG_application); - if(removed && logger != null) { + if(removed ) { logger.logMessage("Attributes on removed: " + HexUtil.toHex8("0x", resourceId) + " (" + nameForLogging + ")"); } @@ -116,7 +117,7 @@ public static void removeAttributeFromManifestAndApplication(AndroidManifestBloc return; } removed = applicationElement.removeAttributesWithId(resourceId); - if(removed && logger != null) { + if(removed ) { logger.logMessage("Attributes on removed: " + HexUtil.toHex8("0x", resourceId) + " (" + nameForLogging + ")"); } @@ -130,6 +131,6 @@ static boolean isNameResourceId(ResXmlAttribute attribute){ return false; } // TODO: could be in reference, use reference resolver - return attribute.getValueType() == ValueType.STRING; + return attribute.getDataType() == DataType.STRING; }; } diff --git a/src/main/java/com/reandroid/apkeditor/core/ExecutionContext.java b/src/main/java/com/reandroid/apkeditor/core/ExecutionContext.java new file mode 100644 index 00000000..e5b7b4b7 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/core/ExecutionContext.java @@ -0,0 +1,42 @@ +package com.reandroid.apkeditor.core; + +/** + * Contexto por hilo para conectar los loggers existentes con la GUI. + * No contiene estado de proyecto ni estado visual. + */ +public final class ExecutionContext { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private ExecutionContext() {} + + public static void setListener(OperationListener listener) { + if (listener == null) { + CURRENT.remove(); + } else { + CURRENT.set(listener); + } + } + + public static OperationListener getListener() { + return CURRENT.get(); + } + + public static void clear() { + CURRENT.remove(); + } + + public static void message(String message) { + OperationListener listener = CURRENT.get(); + if (listener != null) listener.onMessage(message); + } + + public static void warning(String message) { + OperationListener listener = CURRENT.get(); + if (listener != null) listener.onWarning(message); + } + + public static void error(String message, Throwable error) { + OperationListener listener = CURRENT.get(); + if (listener != null) listener.onError(message, error); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/core/LegacyOptionsOperation.java b/src/main/java/com/reandroid/apkeditor/core/LegacyOptionsOperation.java new file mode 100644 index 00000000..01ee752e --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/core/LegacyOptionsOperation.java @@ -0,0 +1,50 @@ +package com.reandroid.apkeditor.core; + +import com.reandroid.apkeditor.Options; + +/** + * Adaptador que permite ejecutar el motor existente sin acoplarlo a Swing. + */ +public class LegacyOptionsOperation implements Operation { + private final String id; + private final String name; + private final Options options; + + public LegacyOptionsOperation(String id, String name, Options options) { + this.id = id; + this.name = name; + this.options = options; + } + + @Override + public String getId() { return id; } + + @Override + public String getName() { return name; } + + public Options getOptions() { return options; } + + @Override + public OperationResult execute(OperationListener listener) throws Exception { + long start = System.currentTimeMillis(); + if (listener != null) listener.onStarted(id, name); + + ExecutionContext.setListener(listener); + try { + options.validate(); + options.runCommand(); + OperationResult result = OperationResult.success( + id, System.currentTimeMillis() - start); + if (listener != null) listener.onCompleted(result); + return result; + } catch (Exception ex) { + if (listener != null) listener.onError(ex.getMessage(), ex); + OperationResult result = OperationResult.failure( + id, System.currentTimeMillis() - start, ex); + if (listener != null) listener.onCompleted(result); + throw ex; + } finally { + ExecutionContext.clear(); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/core/Operation.java b/src/main/java/com/reandroid/apkeditor/core/Operation.java new file mode 100644 index 00000000..055ecff3 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/core/Operation.java @@ -0,0 +1,11 @@ +package com.reandroid.apkeditor.core; + +/** + * Unidad de trabajo independiente de la interfaz. + * CLI y GUI pueden ejecutar las mismas operaciones. + */ +public interface Operation { + String getId(); + String getName(); + OperationResult execute(OperationListener listener) throws Exception; +} diff --git a/src/main/java/com/reandroid/apkeditor/core/OperationListener.java b/src/main/java/com/reandroid/apkeditor/core/OperationListener.java new file mode 100644 index 00000000..f8035584 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/core/OperationListener.java @@ -0,0 +1,10 @@ +package com.reandroid.apkeditor.core; + +public interface OperationListener { + default void onStarted(String operationId, String operationName) {} + default void onProgress(int percent) {} + default void onMessage(String message) {} + default void onWarning(String message) {} + default void onError(String message, Throwable error) {} + default void onCompleted(OperationResult result) {} +} diff --git a/src/main/java/com/reandroid/apkeditor/core/OperationRegistry.java b/src/main/java/com/reandroid/apkeditor/core/OperationRegistry.java new file mode 100644 index 00000000..2a4b5c10 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/core/OperationRegistry.java @@ -0,0 +1,21 @@ +package com.reandroid.apkeditor.core; + +import java.util.*; + +public final class OperationRegistry { + private final Map operations = new LinkedHashMap<>(); + + public OperationRegistry register(Operation operation) { + Objects.requireNonNull(operation, "operation"); + operations.put(operation.getId(), operation); + return this; + } + + public Operation get(String id) { + return operations.get(id); + } + + public Collection all() { + return Collections.unmodifiableCollection(operations.values()); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/core/OperationResult.java b/src/main/java/com/reandroid/apkeditor/core/OperationResult.java new file mode 100644 index 00000000..f5dd2600 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/core/OperationResult.java @@ -0,0 +1,29 @@ +package com.reandroid.apkeditor.core; + +public final class OperationResult { + private final boolean success; + private final String operationId; + private final long durationMillis; + private final Throwable error; + + private OperationResult(boolean success, String operationId, + long durationMillis, Throwable error) { + this.success = success; + this.operationId = operationId; + this.durationMillis = durationMillis; + this.error = error; + } + + public static OperationResult success(String operationId, long durationMillis) { + return new OperationResult(true, operationId, durationMillis, null); + } + + public static OperationResult failure(String operationId, long durationMillis, Throwable error) { + return new OperationResult(false, operationId, durationMillis, error); + } + + public boolean isSuccess() { return success; } + public String getOperationId() { return operationId; } + public long getDurationMillis() { return durationMillis; } + public Throwable getError() { return error; } +} diff --git a/src/main/java/com/reandroid/apkeditor/decompile/Decompiler.java b/src/main/java/com/reandroid/apkeditor/decompile/Decompiler.java index 8d782487..a52383ef 100644 --- a/src/main/java/com/reandroid/apkeditor/decompile/Decompiler.java +++ b/src/main/java/com/reandroid/apkeditor/decompile/Decompiler.java @@ -76,7 +76,7 @@ private ApkModuleDecoder getApkModuleDecoder(ApkModule apkModule) throws IOExcep decoder = xmlDecoder; XmlCoder.getInstance().getSetting().setLogger(this); } - decoder.sanitizeFilePaths(); + decoder.setSanitizePaths(true); decoder.setDexDecoder(getSmaliDecompiler(apkModule)); DexProfileDecoderImpl dexProfileDecoder = new DexProfileDecoderImpl(options); dexProfileDecoder.setApkLogger(this); diff --git a/src/main/java/com/reandroid/apkeditor/gui/GUIApplication.java b/src/main/java/com/reandroid/apkeditor/gui/GUIApplication.java new file mode 100644 index 00000000..afd5cb95 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/GUIApplication.java @@ -0,0 +1,14 @@ +package com.reandroid.apkeditor.gui; + +import javax.swing.SwingUtilities; + +public final class GUIApplication { + private GUIApplication() {} + + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + MainWindow window = new MainWindow(); + window.setVisible(true); + }); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/MainWindow.java b/src/main/java/com/reandroid/apkeditor/gui/MainWindow.java new file mode 100644 index 00000000..5d82350a --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/MainWindow.java @@ -0,0 +1,615 @@ +package com.reandroid.apkeditor.gui; + +import com.reandroid.apkeditor.core.OperationRegistry; +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.components.SidebarButton; +import com.reandroid.apkeditor.gui.components.StatusDot; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.navigation.Route; +import com.reandroid.apkeditor.gui.panel.AppStatusBar; +import com.reandroid.apkeditor.gui.service.ApkService; +import com.reandroid.apkeditor.gui.service.LogService; +import com.reandroid.apkeditor.gui.service.OperationService; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; +import com.reandroid.apkeditor.gui.theme.Theme; +import com.reandroid.apkeditor.gui.view.DashboardView; +import com.reandroid.apkeditor.gui.view.DecompileView; +import com.reandroid.apkeditor.gui.view.InformationView; +import com.reandroid.apkeditor.gui.view.OperationView; +import com.reandroid.apkeditor.gui.view.SettingsView; +import com.reandroid.apkeditor.operations.OperationCatalog; + +import javax.swing.*; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowEvent; +import java.beans.PropertyChangeListener; +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Ventana principal. Compone la barra superior, el sidebar, las vistas y + * la barra de estado. No contiene lógica de descompilación. + */ +public final class MainWindow extends JFrame { + private static final int TOPBAR_HEIGHT = 74; + private static final int SIDEBAR_WIDTH = 236; + private static final String WORKSPACE = "WORKSPACE"; + + private final OperationRegistry registry; + private final AppState state = new AppState(); + private final LogService logs = LogService.get(); + private final OperationService operations; + private final Map navButtons = new LinkedHashMap<>(); + private final PropertyChangeListener inputListener; + private final PropertyChangeListener themeListener; + private final PropertyChangeListener languageListener; + + private CardLayout cards; + private JPanel content; + private DecompileView workspace; + private NeonButton apkButton; + private JComboBox themeSelector; + private JComboBox languageSelector; + private Route currentRoute = Route.DECOMPILE; + private boolean rebuilding; + private boolean maximized; + private Rectangle restoredBounds; + + public MainWindow() { + super(I18n.t("app.title")); + I18n.setLanguage(state.getLanguageCode()); + setTitle(I18n.t("app.title")); + Theme.install(); + registry = OperationCatalog.create(); + operations = new OperationService(registry, logs); + logs.seedDemoIfEmpty(); + + setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + setUndecorated(true); + setMinimumSize(new Dimension(1200, 800)); + configureWindowBounds(); + buildUI(); + installShortcuts(); + + inputListener = event -> SwingUtilities.invokeLater(this::updateApkButton); + themeListener = event -> SwingUtilities.invokeLater( + () -> applyTheme((String) event.getNewValue())); + languageListener = event -> SwingUtilities.invokeLater( + () -> applyLanguage((String) event.getNewValue())); + state.addPropertyChangeListener(AppState.INPUT_CHANGED, inputListener); + state.addPropertyChangeListener(AppState.THEME_CHANGED, themeListener); + state.addPropertyChangeListener(AppState.LANGUAGE_CHANGED, languageListener); + updateApkButton(); + } + + private void configureWindowBounds() { + Dimension screen = Toolkit.getDefaultToolkit().getScreenSize(); + int width = Math.min(1536, Math.max(1000, screen.width - 40)); + int height = Math.min(1024, Math.max(720, screen.height - 70)); + setSize(width, height); + setLocationRelativeTo(null); + } + + private void buildUI() { + JPanel root = new JPanel(new BorderLayout()); + root.setBackground(Colors.BACKGROUND); + root.add(createTopBar(), BorderLayout.NORTH); + + JPanel center = new JPanel(new BorderLayout()); + center.setBackground(Colors.BACKGROUND); + center.add(createSidebar(), BorderLayout.WEST); + + cards = new CardLayout(); + content = new JPanel(cards); + content.setBackground(Colors.BACKGROUND); + buildCards(); + center.add(content, BorderLayout.CENTER); + + root.add(center, BorderLayout.CENTER); + root.add(new AppStatusBar(state), BorderLayout.SOUTH); + setContentPane(root); + revalidate(); + repaint(); + showRoute(currentRoute, false); + } + + private JComponent createTopBar() { + JPanel bar = new JPanel(new BorderLayout(18, 0)); + bar.setPreferredSize(new Dimension(0, TOPBAR_HEIGHT)); + bar.setMinimumSize(new Dimension(0, TOPBAR_HEIGHT)); + bar.setBackground(Colors.BACKGROUND_SECONDARY); + bar.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, Colors.BORDER), + BorderFactory.createEmptyBorder(0, 18, 0, 14))); + + bar.add(createBrand(), BorderLayout.WEST); + bar.add(createApkSelector(), BorderLayout.CENTER); + bar.add(createTopActions(), BorderLayout.EAST); + installDrag(bar); + return bar; + } + + private JComponent createBrand() { + JLabel logo = new JLabel(IconFactory.icon("package", 28, Colors.PRIMARY)); + logo.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 10)); + + JLabel name = new JLabel("APKEDITOR"); + name.setFont(Fonts.TITLE.deriveFont(Font.BOLD, 20f)); + name.setForeground(Colors.TEXT); + + JLabel subtitle = new JLabel(I18n.t("app.brandSubtitle")); + subtitle.setFont(Fonts.SMALL); + subtitle.setForeground(Colors.TEXT_SECONDARY); + + JLabel dot = new JLabel("·"); + dot.setFont(Fonts.SMALL); + dot.setForeground(Colors.TEXT_MUTED); + + JLabel tagline = new JLabel(I18n.t("app.tagline")); + tagline.setFont(Fonts.TINY); + tagline.setForeground(Colors.TEXT_MUTED); + + JPanel meta = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + meta.setOpaque(false); + meta.add(subtitle); + meta.add(dot); + meta.add(tagline); + + JPanel text = new JPanel(new BorderLayout(0, 1)); + text.setOpaque(false); + text.add(name, BorderLayout.NORTH); + text.add(meta, BorderLayout.SOUTH); + + JPanel brand = new JPanel(new BorderLayout()); + brand.setOpaque(false); + brand.add(logo, BorderLayout.WEST); + brand.add(text, BorderLayout.CENTER); + installDrag(brand); + installDrag(logo); + installDrag(name); + installDrag(meta); + return brand; + } + + private JComponent createApkSelector() { + apkButton = new NeonButton(I18n.t("apk.placeholder"), + IconFactory.icon("file-code", 15, Colors.PRIMARY)); + apkButton.setVariant(NeonButton.Variant.SECONDARY); + apkButton.setFont(Fonts.BODY_MEDIUM); + apkButton.setHorizontalAlignment(SwingConstants.LEFT); + apkButton.setIconTextGap(9); + apkButton.setPreferredSize(new Dimension(330, 36)); + apkButton.setMaximumSize(new Dimension(380, 36)); + apkButton.setToolTipText(I18n.t("apk.selectTooltip")); + apkButton.addActionListener(e -> chooseApk()); + + JLabel chevron = new JLabel(IconFactory.icon("chevron-down", 12, + Colors.TEXT_SECONDARY)); + chevron.setBorder(BorderFactory.createEmptyBorder(0, 8, 0, 4)); + chevron.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + chevron.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + apkButton.doClick(); + } + }); + + JPanel wrapper = new JPanel(new BorderLayout()); + wrapper.setOpaque(false); + wrapper.add(apkButton, BorderLayout.CENTER); + wrapper.add(chevron, BorderLayout.EAST); + + JPanel center = new JPanel(new FlowLayout(FlowLayout.CENTER, 0, 0)); + center.setOpaque(false); + center.add(wrapper); + return center; + } + + private JComponent createTopActions() { + JLabel themeLabel = new JLabel(I18n.t("topbar.theme")); + themeLabel.setFont(Fonts.TINY); + themeLabel.setForeground(Colors.TEXT_MUTED); + + themeSelector = new JComboBox<>(Theme.NAMES); + themeSelector.setSelectedItem(state.getThemeName()); + themeSelector.setPreferredSize(new Dimension(120, 30)); + themeSelector.setFont(Fonts.SMALL); + themeSelector.setBackground(Colors.SURFACE_2); + themeSelector.setForeground(Colors.TEXT); + themeSelector.setFocusable(false); + themeSelector.setBorder(BorderFactory.createLineBorder(Colors.BORDER)); + themeSelector.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean selected, boolean focus) { + JLabel label = (JLabel) super.getListCellRendererComponent( + list, value, index, selected, focus); + label.setFont(Fonts.SMALL); + label.setBackground(selected ? Colors.PANEL_HOVER : Colors.SURFACE_2); + label.setForeground(selected ? Colors.PRIMARY_BRIGHT : Colors.TEXT); + label.setBorder(BorderFactory.createEmptyBorder(3, 7, 3, 7)); + return label; + } + }); + themeSelector.addActionListener(e -> { + if (rebuilding) { + return; + } + Object selected = themeSelector.getSelectedItem(); + if (selected != null && !selected.equals(state.getThemeName())) { + state.setThemeName(String.valueOf(selected)); + } + }); + + JLabel languageLabel = new JLabel(I18n.t("topbar.language")); + languageLabel.setFont(Fonts.TINY); + languageLabel.setForeground(Colors.TEXT_MUTED); + + languageSelector = new JComboBox<>(I18n.Language.values()); + languageSelector.setSelectedItem(I18n.getLanguage()); + languageSelector.setPreferredSize(new Dimension(104, 30)); + languageSelector.setFont(Fonts.SMALL); + languageSelector.setBackground(Colors.SURFACE_2); + languageSelector.setForeground(Colors.TEXT); + languageSelector.setFocusable(false); + languageSelector.setBorder(BorderFactory.createLineBorder(Colors.BORDER)); + languageSelector.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean selected, boolean focus) { + JLabel label = (JLabel) super.getListCellRendererComponent( + list, value, index, selected, focus); + label.setFont(Fonts.SMALL); + label.setBackground(selected ? Colors.PANEL_HOVER : Colors.SURFACE_2); + label.setForeground(selected ? Colors.PRIMARY_BRIGHT : Colors.TEXT); + label.setBorder(BorderFactory.createEmptyBorder(3, 7, 3, 7)); + return label; + } + }); + languageSelector.addActionListener(e -> { + if (rebuilding) { + return; + } + Object selected = languageSelector.getSelectedItem(); + if (selected instanceof I18n.Language language + && !language.getCode().equals(state.getLanguageCode())) { + state.setLanguageCode(language.getCode()); + } + }); + + NeonButton minimize = windowButton("minimize", I18n.t("window.minimize")); + minimize.addActionListener(e -> setState(ICONIFIED)); + NeonButton maximize = windowButton("maximize", I18n.t("window.maximize")); + maximize.addActionListener(e -> toggleMaximize()); + NeonButton close = windowButton("close", I18n.t("window.close")); + close.addActionListener(e -> dispatchEvent( + new WindowEvent(this, WindowEvent.WINDOW_CLOSING))); + + JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 7, 0)); + actions.setOpaque(false); + actions.add(themeLabel); + actions.add(themeSelector); + actions.add(languageLabel); + actions.add(languageSelector); + actions.add(Box.createHorizontalStrut(6)); + actions.add(minimize); + actions.add(maximize); + actions.add(close); + return actions; + } + + private NeonButton windowButton(String icon, String tooltip) { + NeonButton button = new NeonButton(IconFactory.icon(icon, 15, + Colors.TEXT_SECONDARY)); + button.setVariant(NeonButton.Variant.GHOST); + button.setCompact(true); + button.setToolTipText(tooltip); + return button; + } + + private JComponent createSidebar() { + JPanel sidebar = new JPanel(new BorderLayout()); + sidebar.setPreferredSize(new Dimension(SIDEBAR_WIDTH, 0)); + sidebar.setMinimumSize(new Dimension(SIDEBAR_WIDTH, 0)); + sidebar.setBackground(Colors.BACKGROUND_SECONDARY); + sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, Colors.BORDER)); + + JPanel nav = new JPanel(); + nav.setLayout(new BoxLayout(nav, BoxLayout.Y_AXIS)); + nav.setBackground(Colors.BACKGROUND_SECONDARY); + nav.setBorder(BorderFactory.createEmptyBorder(16, 12, 12, 12)); + + addNav(nav, I18n.t("nav.dashboard"), "house", Route.DASHBOARD); + addNav(nav, I18n.t("nav.decompile"), "file-code", Route.DECOMPILE); + addNav(nav, I18n.t("nav.analyze"), "scan-search", Route.ANALYZE); + addNav(nav, I18n.t("nav.smaliViewer"), "binary", Route.SMALI_VIEWER); + addNav(nav, I18n.t("nav.build"), "package", Route.BUILD); + addNav(nav, I18n.t("nav.merge"), "git-merge", Route.MERGE); + addNav(nav, I18n.t("nav.refactor"), "workflow", Route.REFACTOR); + addNav(nav, I18n.t("nav.protect"), "shield", Route.PROTECT); + addNav(nav, I18n.t("nav.information"), "info", Route.INFORMATION); + addNav(nav, I18n.t("nav.settings"), "settings", Route.SETTINGS); + nav.add(Box.createVerticalGlue()); + + JScrollPane scroll = new JScrollPane(nav); + scroll.setBorder(BorderFactory.createEmptyBorder()); + scroll.setOpaque(false); + scroll.getViewport().setOpaque(false); + scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scroll.getVerticalScrollBar().setUnitIncrement(18); + + sidebar.add(scroll, BorderLayout.CENTER); + sidebar.add(createSidebarLogo(), BorderLayout.SOUTH); + return sidebar; + } + + private void addNav(JPanel parent, String label, String icon, Route route) { + SidebarButton button = new SidebarButton(label, icon); + button.addActionListener(e -> showRoute(route, true)); + navButtons.put(route, button); + parent.add(button); + parent.add(Box.createVerticalStrut(2)); + } + + private JComponent createSidebarLogo() { + JPanel logo = new JPanel(); + logo.setLayout(new BoxLayout(logo, BoxLayout.Y_AXIS)); + logo.setBackground(Colors.BACKGROUND_SECONDARY); + logo.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, Colors.BORDER), + BorderFactory.createEmptyBorder(14, 18, 18, 18))); + + JLabel icon = new JLabel(IconFactory.icon("package", 22, Colors.PRIMARY)); + JLabel name = new JLabel("APKEDITOR"); + name.setFont(Fonts.BODY.deriveFont(Font.BOLD)); + name.setForeground(Colors.TEXT); + JLabel version = new JLabel(I18n.t("app.version")); + version.setFont(Fonts.TINY); + version.setForeground(Colors.TEXT_MUTED); + JLabel description = new JLabel(I18n.t("app.sidebarTagline")); + description.setFont(Fonts.TINY); + description.setForeground(Colors.TEXT_MUTED); + + icon.setAlignmentX(LEFT_ALIGNMENT); + name.setAlignmentX(LEFT_ALIGNMENT); + version.setAlignmentX(LEFT_ALIGNMENT); + description.setAlignmentX(LEFT_ALIGNMENT); + + logo.add(icon); + logo.add(Box.createVerticalStrut(7)); + logo.add(name); + logo.add(version); + logo.add(Box.createVerticalStrut(4)); + logo.add(description); + return logo; + } + + private void buildCards() { + content.add(new DashboardView(state, this::navigate), Route.DASHBOARD.name()); + + workspace = new DecompileView(state, operations, logs, this::loadApk); + content.add(workspace, WORKSPACE); + + for (Route route : new Route[]{Route.BUILD, Route.MERGE, + Route.REFACTOR, Route.PROTECT}) { + String id = route.name().toLowerCase(); + content.add(new OperationView(id, registry.get(id), state, operations, logs), + route.name()); + } + + content.add(new InformationView(state, this::navigate), Route.INFORMATION.name()); + content.add(new SettingsView(state, registry), Route.SETTINGS.name()); + } + + public void navigate(Route route) { + showRoute(route, true); + } + + private void showRoute(Route route, boolean explicit) { + if (route == Route.DECODE) { + route = Route.DECOMPILE; + } + if (route == null || content == null) { + return; + } + currentRoute = route; + switch (route) { + case DASHBOARD, BUILD, MERGE, REFACTOR, PROTECT, INFORMATION, SETTINGS -> + cards.show(content, route.name()); + case ANALYZE -> { + cards.show(content, WORKSPACE); + workspace.showAnalysis(); + } + case SMALI_VIEWER -> { + cards.show(content, WORKSPACE); + workspace.showEditor(); + } + default -> { + cards.show(content, WORKSPACE); + if (explicit) { + workspace.showStructure(); + } + } + } + for (Map.Entry entry : navButtons.entrySet()) { + entry.getValue().setActive(entry.getKey() == route); + } + state.setActiveRoute(route.name().toLowerCase()); + } + + private void chooseApk() { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(I18n.t("apk.dialogTitle")); + chooser.setFileFilter(new FileNameExtensionFilter( + I18n.t("apk.filter"), "apk", "zip", "aab")); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + loadApk(chooser.getSelectedFile()); + } + } + + public void loadApk(File file) { + if (file == null || !file.exists()) { + logs.warning(I18n.t("log.apk.notExists")); + return; + } + state.setCurrentInput(file); + logs.info(I18n.t("log.apk.loading", file.getName())); + new SwingWorker() { + @Override + protected ApkService.Result doInBackground() throws Exception { + return ApkService.analyze(file); + } + + @Override + protected void done() { + try { + ApkService.Result result = get(); + state.setAnalysis(result.getAnalysis()); + state.setCurrentTree(result.getTree()); + for (String message : result.getMessages()) { + logs.info(message); + } + logs.success(I18n.t("log.apk.loaded", + result.getAnalysis().getAppName(), + result.getAnalysis().getFileCount())); + showRoute(Route.DECOMPILE, true); + } catch (Exception error) { + logs.error(I18n.t("log.apk.analyzeError", shortMessage(error))); + } + } + }.execute(); + } + + private void updateApkButton() { + if (apkButton == null) { + return; + } + File input = state.getCurrentInput(); + apkButton.setText(input == null ? I18n.t("apk.placeholder") : input.getName()); + apkButton.setToolTipText(input == null + ? I18n.t("apk.selectTooltip") : input.getAbsolutePath()); + } + + private void applyTheme(String name) { + if (rebuilding || name == null || name.isBlank()) { + return; + } + rebuilding = true; + try { + Theme.apply(name); + Theme.install(); + buildUI(); + } finally { + rebuilding = false; + } + } + + private void applyLanguage(String code) { + if (rebuilding) { + return; + } + rebuilding = true; + try { + I18n.setLanguage(code); + setTitle(I18n.t("app.title")); + buildUI(); + } finally { + rebuilding = false; + } + } + + private void installShortcuts() { + JRootPane root = getRootPane(); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0), "decode", + e -> { + showRoute(Route.DECOMPILE, true); + workspace.runDecode(); + }); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.CTRL_DOWN_MASK), + "openApk", e -> chooseApk()); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_1, InputEvent.CTRL_DOWN_MASK), + "workspace", e -> showRoute(Route.DECOMPILE, true)); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_COMMA, InputEvent.CTRL_DOWN_MASK), + "settings", e -> showRoute(Route.SETTINGS, true)); + } + + private void bind(JRootPane root, KeyStroke key, String name, + java.awt.event.ActionListener listener) { + root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(key, name); + root.getActionMap().put(name, new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + listener.actionPerformed(e); + } + }); + } + + private void installDrag(Component component) { + MouseAdapter drag = new MouseAdapter() { + private Point origin; + + @Override + public void mousePressed(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e) && !maximized) { + origin = e.getPoint(); + } + } + + @Override + public void mouseDragged(MouseEvent e) { + if (origin == null || maximized) { + return; + } + Point location = e.getLocationOnScreen(); + setLocation(location.x - origin.x, location.y - origin.y); + } + + @Override + public void mouseReleased(MouseEvent e) { + origin = null; + } + + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2 && SwingUtilities.isLeftMouseButton(e)) { + toggleMaximize(); + } + } + }; + component.addMouseListener(drag); + component.addMouseMotionListener(drag); + } + + private void toggleMaximize() { + if (!maximized) { + restoredBounds = getBounds(); + GraphicsConfiguration configuration = getGraphicsConfiguration(); + Rectangle screen = configuration.getBounds(); + Insets insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration); + setBounds(screen.x + insets.left, + screen.y + insets.top, + screen.width - insets.left - insets.right, + screen.height - insets.top - insets.bottom); + maximized = true; + } else if (restoredBounds != null) { + setBounds(restoredBounds); + maximized = false; + } + } + + private String shortMessage(Throwable error) { + String message = error.getMessage(); + return message == null || message.isBlank() + ? error.getClass().getSimpleName() : message; + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/ActionTile.java b/src/main/java/com/reandroid/apkeditor/gui/components/ActionTile.java new file mode 100644 index 00000000..0ae9bfd1 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/ActionTile.java @@ -0,0 +1,93 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.List; + +/** + * Tarjeta de acción con icono, título, descripción y estado hover. + */ +public final class ActionTile extends JPanel { + private final JLabel iconLabel = new JLabel(); + private final JLabel titleLabel = new JLabel(); + private final JLabel descriptionLabel = new JLabel(); + private final String iconName; + private final List actionListeners = new ArrayList<>(); + private boolean hover; + + public ActionTile(String icon, String title, String description) { + super(new BorderLayout(0, 4)); + this.iconName = icon; + setOpaque(false); + setBorder(BorderFactory.createEmptyBorder(14, 14, 14, 14)); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + + iconLabel.setIcon(IconFactory.icon(icon, 20, Colors.PRIMARY)); + titleLabel.setText(title); + titleLabel.setFont(Fonts.BODY.deriveFont(Font.BOLD)); + titleLabel.setForeground(Colors.TEXT); + descriptionLabel.setText("" + description + ""); + descriptionLabel.setFont(Fonts.SMALL); + descriptionLabel.setForeground(Colors.TEXT_MUTED); + + JPanel text = new JPanel(new BorderLayout(0, 2)); + text.setOpaque(false); + text.add(titleLabel, BorderLayout.NORTH); + text.add(descriptionLabel, BorderLayout.CENTER); + + add(iconLabel, BorderLayout.NORTH); + add(text, BorderLayout.CENTER); + + addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mouseEntered(java.awt.event.MouseEvent e) { + hover = true; + repaint(); + } + + @Override + public void mouseExited(java.awt.event.MouseEvent e) { + hover = false; + repaint(); + } + + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + if (e.getClickCount() == 1) { + for (ActionListener listener : actionListeners) { + listener.actionPerformed(new java.awt.event.ActionEvent( + ActionTile.this, java.awt.event.ActionEvent.ACTION_PERFORMED, "click")); + } + } + } + }); + } + + public void addActionListener(ActionListener listener) { + actionListeners.add(listener); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(hover ? Colors.PANEL_HOVER : Colors.alpha(Colors.PANEL_SECONDARY, 170)); + g2.fillRoundRect(1, 1, getWidth() - 2, getHeight() - 2, 9, 9); + g2.setColor(hover ? Colors.BORDER_ACTIVE : Colors.BORDER); + g2.drawRoundRect(1, 1, getWidth() - 3, getHeight() - 3, 9, 9); + iconLabel.setIcon(IconFactory.icon(iconName, 20, + hover ? Colors.PRIMARY_BRIGHT : Colors.PRIMARY)); + } finally { + g2.dispose(); + } + super.paintComponent(g); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/FileSelector.java b/src/main/java/com/reandroid/apkeditor/gui/components/FileSelector.java new file mode 100644 index 00000000..eff3bde2 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/FileSelector.java @@ -0,0 +1,55 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; + +import javax.swing.*; +import java.awt.*; +import java.io.File; + +public final class FileSelector extends JPanel { + private final JTextField field = new JTextField(); + + public FileSelector(String label, boolean directory) { + setLayout(new BorderLayout(8, 4)); + setBackground(Colors.BACKGROUND); + + JLabel title = new JLabel(label); + title.setFont(Fonts.SMALL); + title.setForeground(Colors.TEXT_MUTED); + + NeonButton browse = new NeonButton(I18n.t("fileSelector.browse")); + browse.addActionListener(e -> choose(directory)); + + field.setFont(Fonts.BODY); + field.setBorder(BorderFactory.createLineBorder(Colors.BORDER)); + + JPanel center = new JPanel(new BorderLayout(6, 0)); + center.setOpaque(false); + center.add(field, BorderLayout.CENTER); + center.add(browse, BorderLayout.EAST); + + add(title, BorderLayout.NORTH); + add(center, BorderLayout.CENTER); + } + + private void choose(boolean directory) { + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(directory + ? JFileChooser.DIRECTORIES_ONLY + : JFileChooser.FILES_ONLY); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + field.setText(chooser.getSelectedFile().getAbsolutePath()); + } + } + + public File getFile() { + String value = field.getText().trim(); + return value.isEmpty() ? null : new File(value); + } + + public void setFile(File file) { + field.setText(file == null ? "" : file.getAbsolutePath()); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/LogConsole.java b/src/main/java/com/reandroid/apkeditor/gui/components/LogConsole.java new file mode 100644 index 00000000..8ce9ba8c --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/LogConsole.java @@ -0,0 +1,37 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; + +import javax.swing.*; +import java.awt.*; + +public final class LogConsole extends JPanel { + private final JTextArea area = new JTextArea(); + + public LogConsole() { + setLayout(new BorderLayout()); + setBackground(Colors.SURFACE); + setBorder(BorderFactory.createLineBorder(Colors.BORDER)); + + area.setEditable(false); + area.setFont(Fonts.MONO); + area.setBackground(Colors.SURFACE); + area.setForeground(Colors.TEXT); + area.setLineWrap(true); + area.setWrapStyleWord(true); + + add(new JScrollPane(area), BorderLayout.CENTER); + } + + public void append(String message) { + SwingUtilities.invokeLater(() -> { + area.append(message + System.lineSeparator()); + area.setCaretPosition(area.getDocument().getLength()); + }); + } + + public void clear() { + area.setText(""); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/NeonButton.java b/src/main/java/com/reandroid/apkeditor/gui/components/NeonButton.java new file mode 100644 index 00000000..d4bb429f --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/NeonButton.java @@ -0,0 +1,147 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; + +/** + * Botón redondeado con acento neón. Soporta variantes para acciones + * primarias, secundarias y botones de icono. + */ +public class NeonButton extends JButton { + public enum Variant { PRIMARY, SECONDARY, GHOST, DANGER } + + private Variant variant = Variant.SECONDARY; + private boolean accent; + private boolean compact; + private int radius = 7; + + public NeonButton(String text) { + super(text); + init(); + } + + public NeonButton(String text, Icon icon) { + super(text); + setIcon(icon); + init(); + } + + public NeonButton(Icon icon) { + super(icon); + init(); + compact = true; + setBorder(new EmptyBorder(6, 6, 6, 6)); + } + + private void init() { + setFont(Fonts.BODY_MEDIUM); + setForeground(Colors.TEXT); + setBackground(Colors.SURFACE_2); + setFocusPainted(false); + setBorderPainted(false); + setContentAreaFilled(false); + setOpaque(false); + setRolloverEnabled(true); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setBorder(new EmptyBorder(8, 14, 8, 14)); + } + + public void setAccent(boolean accent) { + this.accent = accent; + this.variant = accent ? Variant.PRIMARY : Variant.SECONDARY; + setForeground(accent ? Colors.BACKGROUND : Colors.TEXT); + repaint(); + } + + public NeonButton setVariant(Variant variant) { + this.variant = variant == null ? Variant.SECONDARY : variant; + this.accent = variant == Variant.PRIMARY; + return this; + } + + public void setCompact(boolean compact) { + this.compact = compact; + setBorder(compact ? new EmptyBorder(5, 5, 5, 5) + : new EmptyBorder(8, 14, 8, 14)); + revalidate(); + } + + public void setRadius(int radius) { + this.radius = radius; + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + boolean hover = getModel().isRollover(); + boolean pressed = getModel().isPressed(); + boolean enabled = isEnabled(); + + Color bg; + Color border; + Color fg; + switch (variant) { + case PRIMARY -> { + bg = hover ? Colors.PRIMARY_BRIGHT : Colors.PRIMARY; + if (pressed) { + bg = Colors.PRIMARY.darker(); + } + border = Colors.PRIMARY_BRIGHT; + fg = Colors.BACKGROUND; + } + case DANGER -> { + bg = hover ? Colors.alpha(Colors.ERROR, 55) + : Colors.alpha(Colors.ERROR, 25); + border = Colors.alpha(Colors.ERROR, 180); + fg = Colors.ERROR; + } + case GHOST -> { + bg = hover ? Colors.alpha(Colors.PANEL_HOVER, 180) + : Colors.alpha(Colors.SURFACE, 0); + border = hover ? Colors.BORDER_ACTIVE : Colors.alpha(Colors.BORDER, 0); + fg = hover ? Colors.PRIMARY_BRIGHT : Colors.TEXT_SECONDARY; + } + default -> { + bg = hover ? Colors.PANEL_HOVER : Colors.SURFACE_2; + border = hover ? Colors.BORDER_ACTIVE : Colors.BORDER; + fg = hover ? Colors.PRIMARY_BRIGHT : Colors.TEXT; + } + } + if (!enabled) { + bg = Colors.alpha(Colors.SURFACE_2, 120); + border = Colors.alpha(Colors.BORDER, 120); + fg = Colors.TEXT_MUTED; + } + + if (hover && enabled) { + g2.setColor(Colors.alpha(Colors.GLOW, 35)); + g2.fillRoundRect(1, 1, getWidth() - 2, getHeight() - 1, + radius + 2, radius + 2); + } + + g2.setColor(bg); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), radius, radius); + + if (variant == Variant.PRIMARY && enabled) { + g2.setPaint(new GradientPaint(0, 0, Colors.PRIMARY_BRIGHT, + 0, getHeight(), Colors.PRIMARY)); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), radius, radius); + } + + g2.setColor(border); + g2.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, radius, radius); + + setForeground(fg); + } finally { + g2.dispose(); + } + super.paintComponent(g); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/NeonProgressBar.java b/src/main/java/com/reandroid/apkeditor/gui/components/NeonProgressBar.java new file mode 100644 index 00000000..bebe357c --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/NeonProgressBar.java @@ -0,0 +1,119 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; + +import javax.swing.*; +import java.awt.*; + +/** + * Barra de progreso neón con brillo y porcentaje opcional. + */ +public final class NeonProgressBar extends JComponent { + private int value; + private boolean showPercentage = true; + private boolean animated = true; + private int shimmer; + private final Timer timer; + private Color accent = Colors.PRIMARY; + + public NeonProgressBar() { + setFont(getFont()); + setPreferredSize(new Dimension(240, 18)); + timer = new Timer(55, e -> { + shimmer = (shimmer + 5) % 200; + repaint(); + }); + } + + public void setValue(int value) { + this.value = Math.max(0, Math.min(100, value)); + repaint(); + } + + public int getValue() { + return value; + } + + public void setShowPercentage(boolean showPercentage) { + this.showPercentage = showPercentage; + repaint(); + } + + public void setAnimated(boolean animated) { + this.animated = animated; + if (!animated) { + timer.stop(); + } + } + + public void setAccent(Color accent) { + this.accent = accent == null ? Colors.PRIMARY : accent; + repaint(); + } + + @Override + public void addNotify() { + super.addNotify(); + if (animated && value < 100) { + timer.start(); + } + } + + @Override + public void removeNotify() { + timer.stop(); + super.removeNotify(); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + int labelWidth = showPercentage ? 42 : 0; + int barWidth = Math.max(20, getWidth() - labelWidth); + int barHeight = Math.min(9, Math.max(6, getHeight() - 4)); + int y = (getHeight() - barHeight) / 2; + + g2.setColor(Colors.BACKGROUND_SECONDARY); + g2.fillRoundRect(0, y, barWidth, barHeight, barHeight, barHeight); + g2.setColor(Colors.alpha(Colors.BORDER, 180)); + g2.drawRoundRect(0, y, barWidth - 1, barHeight - 1, barHeight, barHeight); + + int fill = Math.round(barWidth * (value / 100f)); + if (fill > 3) { + g2.setPaint(new GradientPaint(0, y, Colors.alpha(accent, 220), + barWidth, y, Colors.PRIMARY_BRIGHT)); + g2.fillRoundRect(0, y, fill, barHeight, barHeight, barHeight); + g2.setColor(Colors.alpha(accent, 80)); + g2.fillRoundRect(0, y - 2, fill, barHeight + 4, barHeight + 4, barHeight + 4); + + if (animated) { + int shine = (int) ((shimmer / 200f) * Math.max(1, fill)); + g2.setColor(Colors.alpha(Color.WHITE, 55)); + g2.fillRoundRect(Math.min(fill - 6, shine), y, + Math.min(28, fill), barHeight, barHeight, barHeight); + } + } + + if (showPercentage) { + g2.setFont(getFont()); + g2.setColor(Colors.PRIMARY_BRIGHT); + FontMetrics fm = g2.getFontMetrics(); + String text = value + "%"; + int tx = barWidth + (labelWidth - fm.stringWidth(text)) / 2; + int ty = (getHeight() - fm.getHeight()) / 2 + fm.getAscent(); + g2.drawString(text, tx, ty); + } + + if (animated && value < 100 && isShowing() && !timer.isRunning()) { + timer.start(); + } else if ((!animated || value >= 100) && timer.isRunning()) { + timer.stop(); + } + } finally { + g2.dispose(); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/PanelCard.java b/src/main/java/com/reandroid/apkeditor/gui/components/PanelCard.java new file mode 100644 index 00000000..94d5c4c7 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/PanelCard.java @@ -0,0 +1,134 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; + +/** + * Contenedor glass/neon reutilizable con cabecera y acciones. + */ +public class PanelCard extends JPanel { + private final JPanel header = new JPanel(new BorderLayout(8, 0)); + private final JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0)); + private final JPanel body = new JPanel(new BorderLayout()); + private final JLabel titleLabel = new JLabel(); + private final JLabel subtitleLabel = new JLabel(); + private final JLabel iconLabel = new JLabel(); + private Color background; + private Color borderColor; + private int radius = 12; + + public PanelCard(String title, String iconName) { + this(title, iconName, null); + } + + public PanelCard(String title, String iconName, String subtitle) { + super(new BorderLayout(0, 10)); + setOpaque(false); + setBorder(new EmptyBorder(14, 14, 14, 14)); + + background = Colors.SURFACE; + borderColor = Colors.BORDER; + + iconLabel.setIcon(IconFactory.icon(iconName, 17, Colors.PRIMARY)); + titleLabel.setText(title); + titleLabel.setFont(Fonts.SECTION); + titleLabel.setForeground(Colors.TEXT); + + subtitleLabel.setText(subtitle == null ? "" : subtitle); + subtitleLabel.setFont(Fonts.TINY); + subtitleLabel.setForeground(Colors.TEXT_MUTED); + subtitleLabel.setVisible(subtitle != null && !subtitle.isBlank()); + + JPanel titles = new JPanel(new BorderLayout(0, 1)); + titles.setOpaque(false); + titles.add(titleLabel, BorderLayout.NORTH); + titles.add(subtitleLabel, BorderLayout.SOUTH); + + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); + left.setOpaque(false); + left.add(iconLabel); + left.add(titles); + + actions.setOpaque(false); + header.setOpaque(false); + header.add(left, BorderLayout.WEST); + header.add(actions, BorderLayout.EAST); + + body.setOpaque(false); + super.add(header, BorderLayout.NORTH); + super.add(body, BorderLayout.CENTER); + } + + public JPanel getBody() { + return body; + } + + public JPanel getHeader() { + return header; + } + + public JPanel getActions() { + return actions; + } + + public JLabel getTitleLabel() { + return titleLabel; + } + + public void setTitle(String title) { + titleLabel.setText(title); + } + + public void setSubtitle(String subtitle) { + subtitleLabel.setText(subtitle == null ? "" : subtitle); + subtitleLabel.setVisible(subtitle != null && !subtitle.isBlank()); + } + + public void setIcon(String iconName) { + iconLabel.setIcon(IconFactory.icon(iconName, 17, Colors.PRIMARY)); + } + + public void addHeaderAction(JComponent component) { + actions.add(component); + } + + public void setBackgroundColor(Color background) { + this.background = background; + } + + public void setBorderColor(Color borderColor) { + this.borderColor = borderColor; + } + + public void setRadius(int radius) { + this.radius = radius; + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(background); + g2.fillRoundRect(0, 0, getWidth() - 1, getHeight() - 1, radius, radius); + + Paint oldPaint = g2.getPaint(); + g2.setPaint(new GradientPaint(0, 0, Colors.alpha(Colors.PRIMARY, 30), + 0, Math.min(70, getHeight()), Colors.alpha(Colors.PRIMARY, 0))); + g2.drawLine(radius, 1, Math.max(radius, getWidth() - radius), 1); + g2.setPaint(oldPaint); + + g2.setColor(borderColor); + g2.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, radius, radius); + } finally { + g2.dispose(); + } + super.paintComponent(g); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/ProgressPanel.java b/src/main/java/com/reandroid/apkeditor/gui/components/ProgressPanel.java new file mode 100644 index 00000000..ed91c70a --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/ProgressPanel.java @@ -0,0 +1,33 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.theme.Colors; + +import javax.swing.*; +import java.awt.*; + +public final class ProgressPanel extends JPanel { + private final JLabel status = new JLabel(I18n.t("progress.ready")); + private final JProgressBar progress = new JProgressBar(); + + public ProgressPanel() { + setLayout(new BorderLayout(10, 0)); + setBackground(Colors.SURFACE); + setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); + status.setForeground(Colors.TEXT); + progress.setIndeterminate(false); + add(status, BorderLayout.WEST); + add(progress, BorderLayout.CENTER); + } + + public void start(String text) { + status.setText(text); + progress.setIndeterminate(true); + } + + public void complete(boolean ok) { + progress.setIndeterminate(false); + progress.setValue(100); + status.setText(I18n.t(ok ? "progress.done" : "progress.failed")); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/SearchField.java b/src/main/java/com/reandroid/apkeditor/gui/components/SearchField.java new file mode 100644 index 00000000..b4419ae1 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/SearchField.java @@ -0,0 +1,95 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import javax.swing.event.DocumentListener; +import java.awt.*; + +/** + * Campo de búsqueda con icono y placeholder dibujado, sin dependencias externas. + */ +public final class SearchField extends JPanel { + private final PlaceholderField field; + + public SearchField(String placeholder) { + super(new BorderLayout(6, 0)); + setOpaque(false); + setBorder(BorderFactory.createEmptyBorder(5, 9, 5, 9)); + + JLabel icon = new JLabel(IconFactory.icon("search", 15, Colors.TEXT_MUTED)); + field = new PlaceholderField(placeholder); + field.setOpaque(false); + field.setBorder(BorderFactory.createEmptyBorder()); + field.setFont(Fonts.SMALL); + field.setForeground(Colors.TEXT); + field.setCaretColor(Colors.PRIMARY); + field.setSelectionColor(Colors.PANEL_HOVER); + field.setSelectedTextColor(Colors.PRIMARY_BRIGHT); + + add(icon, BorderLayout.WEST); + add(field, BorderLayout.CENTER); + } + + public JTextField getField() { + return field; + } + + public String getText() { + return field.getText(); + } + + public void setText(String text) { + field.setText(text); + } + + public void addDocumentListener(DocumentListener listener) { + field.getDocument().addDocumentListener(listener); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(Colors.BACKGROUND_SECONDARY); + g2.fillRoundRect(0, 0, getWidth() - 1, getHeight() - 1, 7, 7); + g2.setColor(field.hasFocus() ? Colors.BORDER_ACTIVE : Colors.BORDER); + g2.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, 7, 7); + } finally { + g2.dispose(); + } + super.paintComponent(g); + } + + private static final class PlaceholderField extends JTextField { + private final String placeholder; + + private PlaceholderField(String placeholder) { + this.placeholder = placeholder; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + if (getText().isEmpty() && !hasFocus()) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setColor(Colors.TEXT_MUTED); + g2.setFont(getFont()); + Insets insets = getInsets(); + FontMetrics fm = g2.getFontMetrics(); + int y = (getHeight() - fm.getHeight()) / 2 + fm.getAscent(); + g2.drawString(placeholder, insets.left + 2, y); + } finally { + g2.dispose(); + } + } + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/SidebarButton.java b/src/main/java/com/reandroid/apkeditor/gui/components/SidebarButton.java new file mode 100644 index 00000000..73decfa4 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/SidebarButton.java @@ -0,0 +1,88 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import java.awt.*; + +/** + * Elemento de navegación lateral con indicador neón y estado activo. + */ +public final class SidebarButton extends JButton { + private final String label; + private final String iconName; + private boolean active; + + public SidebarButton(String label, String iconName) { + this.label = label; + this.iconName = iconName; + setText(""); + setFocusPainted(false); + setBorderPainted(false); + setContentAreaFilled(false); + setOpaque(false); + setRolloverEnabled(true); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setFont(Fonts.BODY); + setPreferredSize(new Dimension(208, 40)); + setMinimumSize(new Dimension(120, 40)); + setMaximumSize(new Dimension(Integer.MAX_VALUE, 40)); + setToolTipText(label); + } + + public String getLabelText() { + return label; + } + + public void setActive(boolean active) { + this.active = active; + repaint(); + } + + public boolean isActive() { + return active; + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + boolean hover = getModel().isRollover(); + if (active) { + g2.setColor(Colors.NAV_ACTIVE_BG); + g2.fillRoundRect(6, 2, getWidth() - 10, getHeight() - 4, 8, 8); + g2.setColor(Colors.alpha(Colors.PRIMARY, 60)); + g2.drawRoundRect(6, 2, getWidth() - 10, getHeight() - 4, 8, 8); + g2.setColor(Colors.NAV_ACTIVE_TEXT); + g2.fillRoundRect(0, 9, 3, getHeight() - 18, 3, 3); + } else if (hover) { + g2.setColor(Colors.alpha(Colors.PANEL_HOVER, 165)); + g2.fillRoundRect(6, 2, getWidth() - 10, getHeight() - 4, 8, 8); + } + + Color iconColor = active ? Colors.NAV_ACTIVE_TEXT + : (hover ? Colors.PRIMARY_BRIGHT : Colors.TEXT_SECONDARY); + Icon icon = IconFactory.icon(iconName, 18, iconColor); + icon.paintIcon(this, g2, 16, (getHeight() - 18) / 2); + + g2.setFont(active ? Fonts.BODY.deriveFont(Font.BOLD) : Fonts.BODY); + g2.setColor(active ? Colors.NAV_ACTIVE_TEXT + : (hover ? Colors.TEXT : Colors.TEXT_SECONDARY)); + FontMetrics fm = g2.getFontMetrics(); + int y = (getHeight() - fm.getHeight()) / 2 + fm.getAscent(); + g2.drawString(label, 46, y); + + if (active) { + g2.setColor(Colors.alpha(Colors.PRIMARY_BRIGHT, 180)); + int dot = 4; + g2.fillOval(getWidth() - 18, (getHeight() - dot) / 2, dot, dot); + } + } finally { + g2.dispose(); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/components/StatusDot.java b/src/main/java/com/reandroid/apkeditor/gui/components/StatusDot.java new file mode 100644 index 00000000..2e544d9f --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/components/StatusDot.java @@ -0,0 +1,40 @@ +package com.reandroid.apkeditor.gui.components; + +import com.reandroid.apkeditor.gui.theme.Colors; + +import javax.swing.*; +import java.awt.*; + +public final class StatusDot extends JComponent { + private Color color; + + public StatusDot(Color color) { + this.color = color; + setPreferredSize(new Dimension(10, 10)); + setMinimumSize(new Dimension(10, 10)); + setMaximumSize(new Dimension(10, 10)); + } + + public void setColor(Color color) { + this.color = color; + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + int size = Math.min(getWidth(), getHeight()); + int x = (getWidth() - size) / 2; + int y = (getHeight() - size) / 2; + g2.setColor(Colors.alpha(color, 45)); + g2.fillOval(x - 3, y - 3, size + 6, size + 6); + g2.setColor(color); + g2.fillOval(x + 1, y + 1, size - 2, size - 2); + } finally { + g2.dispose(); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/editor/CodeEditor.java b/src/main/java/com/reandroid/apkeditor/gui/editor/CodeEditor.java new file mode 100644 index 00000000..abdadc76 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/editor/CodeEditor.java @@ -0,0 +1,274 @@ +package com.reandroid.apkeditor.gui.editor; + +import com.reandroid.apkeditor.gui.model.SourceFile; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; + +import javax.swing.*; +import javax.swing.event.CaretListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.*; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.Consumer; + +/** + * Editor de código real basado en JTextPane + StyledDocument. + * Incluye numeración de líneas, línea actual y resaltado Smali. + */ +public final class CodeEditor extends JPanel { + private final SourceFile sourceFile; + private final JTextPane textPane = new JTextPane(); + private final JScrollPane scrollPane; + private final LineNumberGutter gutter; + private final Timer highlightTimer; + private Object lineHighlight; + private Consumer caretConsumer; + + public CodeEditor(SourceFile sourceFile) { + super(new BorderLayout()); + this.sourceFile = sourceFile; + setBackground(Colors.SURFACE); + + textPane.setFont(Fonts.CODE); + textPane.setForeground(Colors.TEXT); + textPane.setBackground(Colors.SURFACE); + textPane.setCaretColor(Colors.PRIMARY_BRIGHT); + textPane.setSelectionColor(Colors.PANEL_HOVER); + textPane.setSelectedTextColor(Colors.TEXT); + textPane.setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10)); + textPane.setMargin(new Insets(8, 10, 8, 10)); + textPane.getDocument().putProperty(PlainDocument.tabSizeAttribute, 4); + + scrollPane = new JScrollPane(textPane); + scrollPane.setBorder(BorderFactory.createEmptyBorder()); + scrollPane.getViewport().setBackground(Colors.SURFACE); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scrollPane.getVerticalScrollBar().setUnitIncrement(18); + + gutter = new LineNumberGutter(textPane, scrollPane); + scrollPane.setRowHeaderView(gutter); + + textPane.setText(sourceFile.getContent()); + doc().putProperty(PlainDocument.tabSizeAttribute, 4); + + highlightTimer = new Timer(130, e -> { + SmaliHighlighter.highlight(textPane, sourceFile.getLanguage()); + gutter.refresh(); + applyLineHighlight(); + }); + highlightTimer.setRepeats(false); + + installListeners(); + add(scrollPane, BorderLayout.CENTER); + + SwingUtilities.invokeLater(() -> { + SmaliHighlighter.highlight(textPane, sourceFile.getLanguage()); + gutter.refresh(); + textPane.setCaretPosition(0); + applyLineHighlight(); + }); + } + + private void installListeners() { + doc().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + changed(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + changed(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + // Cambios de estilo no requieren re-resaltado. + } + }); + textPane.addCaretListener(e -> { + gutter.repaint(); + applyLineHighlight(); + if (caretConsumer != null) { + caretConsumer.accept(getLineColumn()); + } + }); + scrollPane.getViewport().addChangeListener(e -> gutter.repaint()); + textPane.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + textPane.requestFocusInWindow(); + } + }); + } + + private void changed() { + gutter.refresh(); + highlightTimer.restart(); + } + + private void applyLineHighlight() { + Highlighter highlighter = textPane.getHighlighter(); + if (lineHighlight != null) { + highlighter.removeHighlight(lineHighlight); + lineHighlight = null; + } + int position = textPane.getCaretPosition(); + Element root = doc().getDefaultRootElement(); + int index = root.getElementIndex(position); + Element line = root.getElement(index); + int start = line.getStartOffset(); + int end = Math.min(line.getEndOffset(), doc().getLength()); + if (end > start) { + try { + lineHighlight = highlighter.addHighlight(start, end, + new DefaultHighlighter.DefaultHighlightPainter( + Colors.alpha(Colors.PRIMARY, 16))); + } catch (BadLocationException ignored) { + } + } + } + + public SourceFile getSourceFile() { + return sourceFile; + } + + public JTextPane getTextPane() { + return textPane; + } + + public StyledDocument doc() { + return (StyledDocument) textPane.getDocument(); + } + + public void setCaretConsumer(Consumer caretConsumer) { + this.caretConsumer = caretConsumer; + } + + public void addCaretListener(CaretListener listener) { + textPane.addCaretListener(listener); + } + + public int[] getLineColumn() { + int position = textPane.getCaretPosition(); + Element root = doc().getDefaultRootElement(); + int line = root.getElementIndex(position); + int start = root.getElement(line).getStartOffset(); + return new int[]{line + 1, position - start + 1}; + } + + public void goToLine(int oneBasedLine) { + Element root = doc().getDefaultRootElement(); + int line = Math.max(0, Math.min(root.getElementCount() - 1, oneBasedLine - 1)); + int offset = root.getElement(line).getStartOffset(); + textPane.setCaretPosition(offset); + textPane.requestFocusInWindow(); + } + + public boolean find(String query, boolean forward) { + if (query == null || query.isEmpty()) { + return false; + } + String text = textPane.getText(); + int caret = textPane.getCaretPosition(); + int index; + if (forward) { + index = text.indexOf(query, caret); + if (index < 0) { + index = text.indexOf(query); + } + } else { + index = text.lastIndexOf(query, Math.max(0, caret - 1)); + if (index < 0) { + index = text.lastIndexOf(query); + } + } + if (index < 0) { + return false; + } + textPane.select(index, index + query.length()); + return true; + } + + public void copy() { + String selected = textPane.getSelectedText(); + if (selected == null || selected.isEmpty()) { + selected = textPane.getText(); + } + java.awt.datatransfer.StringSelection selection = + new java.awt.datatransfer.StringSelection(selected); + java.awt.Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(selection, selection); + } + + /** + * Gutter con numeración y desplazamiento sincronizado con el viewport. + */ + private static final class LineNumberGutter extends JComponent { + private final JTextPane textPane; + private final JScrollPane scrollPane; + private int lastDigits; + + private LineNumberGutter(JTextPane textPane, JScrollPane scrollPane) { + this.textPane = textPane; + this.scrollPane = scrollPane; + setOpaque(false); + setPreferredSize(new Dimension(48, 0)); + } + + private void refresh() { + int lines = textPane.getDocument().getDefaultRootElement().getElementCount(); + int digits = Math.max(2, String.valueOf(lines).length()); + if (digits != lastDigits) { + lastDigits = digits; + FontMetrics fm = getFontMetrics(Fonts.MONO_SMALL); + setPreferredSize(new Dimension(16 + fm.charWidth('0') * digits, 0)); + revalidate(); + } + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setColor(Colors.BACKGROUND_SECONDARY); + g2.fillRect(0, 0, getWidth(), getHeight()); + g2.setColor(Colors.BORDER); + g2.drawLine(getWidth() - 1, 0, getWidth() - 1, getHeight()); + + FontMetrics fm = g2.getFontMetrics(Fonts.MONO_SMALL); + g2.setFont(Fonts.MONO_SMALL); + int lineHeight = Math.max(1, fm.getHeight()); + int viewY = scrollPane.getViewport().getViewPosition().y; + int startLine = Math.max(0, viewY / lineHeight); + int endLine = startLine + getHeight() / lineHeight + 2; + int total = textPane.getDocument().getDefaultRootElement().getElementCount(); + int caretLine = textPane.getDocument().getDefaultRootElement() + .getElementIndex(textPane.getCaretPosition()); + + for (int line = startLine; line < Math.min(total, endLine); line++) { + int y = (line * lineHeight) - viewY + fm.getAscent() + 6; + if (y < -lineHeight || y > getHeight() + lineHeight) { + continue; + } + g2.setColor(line == caretLine ? Colors.PRIMARY_BRIGHT : Colors.TEXT_MUTED); + g2.setFont(line == caretLine + ? Fonts.MONO_SMALL.deriveFont(Font.BOLD) + : Fonts.MONO_SMALL); + String value = String.valueOf(line + 1); + int x = getWidth() - 10 - fm.stringWidth(value); + g2.drawString(value, x, y); + } + } finally { + g2.dispose(); + } + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/editor/EditorTabs.java b/src/main/java/com/reandroid/apkeditor/gui/editor/EditorTabs.java new file mode 100644 index 00000000..fab0f3f5 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/editor/EditorTabs.java @@ -0,0 +1,507 @@ +package com.reandroid.apkeditor.gui.editor; + +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.components.SearchField; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.model.SourceFile; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Pestañas de edición con editor real por documento y barra de estado. + */ +public final class EditorTabs extends JPanel { + private final AppState state; + private final CardLayout cards = new CardLayout(); + private final JPanel content = new JPanel(cards); + private final JPanel tabBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + private final Map editors = new LinkedHashMap<>(); + private final Map tabs = new LinkedHashMap<>(); + private final JLabel caretLabel = new JLabel(I18n.t("editor.caret", 1, 1)); + private final JLabel languageLabel = new JLabel("Smali"); + private final JLabel spacesLabel = new JLabel(I18n.t("editor.spaces")); + private final JLabel encodingLabel = new JLabel("UTF-8"); + private final SearchField searchField = new SearchField( + I18n.t("editor.searchPlaceholder")); + private final JPanel searchBar = new JPanel(new BorderLayout()); + private final JPanel emptyState = new JPanel(new GridBagLayout()); + private String activeKey; + private final List> activeFileListeners = new ArrayList<>(); + + public EditorTabs(AppState state, SourceFile initial) { + super(new BorderLayout()); + this.state = state; + setBackground(Colors.SURFACE); + + tabBar.setOpaque(false); + tabBar.setBorder(new EmptyBorder(0, 0, 0, 0)); + + JPanel header = new JPanel(new BorderLayout(8, 0)); + header.setOpaque(false); + header.setBorder(new EmptyBorder(9, 12, 7, 12)); + header.add(tabBar, BorderLayout.CENTER); + header.add(createToolbar(), BorderLayout.EAST); + + searchBar.setOpaque(false); + searchBar.setBorder(new EmptyBorder(0, 12, 8, 12)); + searchBar.add(searchField, BorderLayout.CENTER); + searchBar.setVisible(false); + searchField.getField().addActionListener(e -> findNext()); + searchField.getField().getInputMap().put( + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "hideSearch"); + searchField.getField().getActionMap().put("hideSearch", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + toggleSearch(false); + } + }); + + JPanel top = new JPanel(new BorderLayout()); + top.setOpaque(false); + top.add(header, BorderLayout.NORTH); + top.add(searchBar, BorderLayout.SOUTH); + + content.setOpaque(false); + emptyState.setOpaque(false); + JLabel empty = new JLabel(I18n.t("editor.empty"), SwingConstants.CENTER); + empty.setForeground(Colors.TEXT_MUTED); + empty.setFont(Fonts.BODY); + emptyState.add(empty); + content.add(emptyState, "__empty__"); + + add(top, BorderLayout.NORTH); + add(content, BorderLayout.CENTER); + add(createStatusBar(), BorderLayout.SOUTH); + + List previous = state.getOpenFiles(); + if (!previous.isEmpty()) { + for (SourceFile file : previous) { + openInternal(file, false); + } + String selected = state.getSelectedFilePath(); + if (selected != null && editors.containsKey(selected)) { + select(selected); + } else if (!editors.isEmpty()) { + select(editors.keySet().iterator().next()); + } + } else if (initial != null) { + openInternal(initial, true); + } + updateEmptyState(); + } + + public void addActiveFileListener(Consumer listener) { + activeFileListeners.add(listener); + } + + private JComponent createToolbar() { + JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0)); + toolbar.setOpaque(false); + NeonButton search = iconButton("search", I18n.t("editor.searchTooltip")); + search.addActionListener(e -> toggleSearch(true)); + NeonButton copy = iconButton("copy", I18n.t("editor.copyTooltip")); + copy.addActionListener(e -> { + CodeEditor editor = getActiveEditor(); + if (editor != null) { + editor.copy(); + } + }); + NeonButton more = iconButton("settings", I18n.t("editor.optionsTooltip")); + more.addActionListener(e -> showEditorMenu(more)); + toolbar.add(search); + toolbar.add(copy); + toolbar.add(more); + return toolbar; + } + + private NeonButton iconButton(String icon, String tooltip) { + NeonButton button = new NeonButton(IconFactory.icon(icon, 15, Colors.TEXT_SECONDARY)); + button.setVariant(NeonButton.Variant.GHOST); + button.setCompact(true); + button.setToolTipText(tooltip); + return button; + } + + private JComponent createStatusBar() { + JPanel bar = new JPanel(new BorderLayout(12, 0)); + bar.setOpaque(true); + bar.setBackground(Colors.BACKGROUND_SECONDARY); + bar.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, Colors.BORDER), + new EmptyBorder(4, 12, 4, 12))); + + caretLabel.setFont(Fonts.MONO_SMALL); + caretLabel.setForeground(Colors.TEXT_SECONDARY); + + JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 12, 0)); + right.setOpaque(false); + for (JLabel label : new JLabel[]{spacesLabel, encodingLabel, languageLabel}) { + label.setFont(Fonts.MONO_SMALL); + label.setForeground(Colors.TEXT_MUTED); + } + languageLabel.setForeground(Colors.PRIMARY_BRIGHT); + right.add(spacesLabel); + right.add(separator()); + right.add(encodingLabel); + right.add(separator()); + right.add(languageLabel); + + bar.add(caretLabel, BorderLayout.WEST); + bar.add(right, BorderLayout.EAST); + return bar; + } + + private JLabel separator() { + JLabel label = new JLabel("•"); + label.setForeground(Colors.TEXT_MUTED); + label.setFont(Fonts.TINY); + return label; + } + + public void open(SourceFile file) { + openInternal(file, true); + } + + private void openInternal(SourceFile file, boolean activate) { + if (file == null) { + return; + } + String key = file.getPath(); + if (editors.containsKey(key)) { + if (activate) { + select(key); + } + return; + } + CodeEditor editor = new CodeEditor(file); + editors.put(key, editor); + content.add(editor, key); + + TabComponent tab = new TabComponent(file); + tabs.put(key, tab); + tabBar.add(tab); + tabBar.revalidate(); + tabBar.repaint(); + + editor.setCaretConsumer(values -> { + if (key.equals(activeKey)) { + updateCaret(values); + } + }); + installEditorKeys(editor); + + state.addOpenFile(file); + state.setSelectedFilePath(key); + + if (activate) { + select(key); + } + updateEmptyState(); + } + + private void installEditorKeys(CodeEditor editor) { + JTextPane pane = editor.getTextPane(); + pane.getInputMap().put( + KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK), "find"); + pane.getActionMap().put("find", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + toggleSearch(true); + } + }); + pane.getInputMap().put( + KeyStroke.getKeyStroke(KeyEvent.VK_G, InputEvent.CTRL_DOWN_MASK), "goto"); + pane.getActionMap().put("goto", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + gotoLineDialog(); + } + }); + pane.getInputMap().put( + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "hideSearch"); + pane.getActionMap().put("hideSearch", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + toggleSearch(false); + } + }); + } + + public void close(SourceFile file) { + if (file == null) { + return; + } + String key = file.getPath(); + CodeEditor editor = editors.remove(key); + TabComponent tab = tabs.remove(key); + if (editor == null) { + return; + } + content.remove(editor); + if (tab != null) { + tabBar.remove(tab); + } + if (key.equals(activeKey)) { + activeKey = null; + if (!editors.isEmpty()) { + select(editors.keySet().iterator().next()); + } + } + state.setOpenFiles(new ArrayList<>(editors.values().stream() + .map(CodeEditor::getSourceFile).toList())); + state.setSelectedFilePath(activeKey); + tabBar.revalidate(); + tabBar.repaint(); + updateEmptyState(); + } + + public void closeActive() { + CodeEditor editor = getActiveEditor(); + if (editor != null) { + close(editor.getSourceFile()); + } + } + + public void select(String key) { + CodeEditor editor = editors.get(key); + if (editor == null) { + return; + } + activeKey = key; + cards.show(content, key); + for (Map.Entry entry : tabs.entrySet()) { + entry.getValue().setActive(entry.getKey().equals(key)); + } + SourceFile file = editor.getSourceFile(); + languageLabel.setText(displayLanguage(file.getLanguage())); + state.setSelectedFilePath(key); + updateCaret(editor.getLineColumn()); + for (Consumer listener : activeFileListeners) { + listener.accept(file); + } + } + + public void selectFile(String path) { + if (path != null && editors.containsKey(path)) { + select(path); + } + } + + public CodeEditor getActiveEditor() { + return activeKey == null ? null : editors.get(activeKey); + } + + public List getOpenFiles() { + return editors.values().stream().map(CodeEditor::getSourceFile).toList(); + } + + private void updateCaret(int[] values) { + caretLabel.setText(I18n.t("editor.caret", values[0], values[1])); + } + + private void toggleSearch(boolean show) { + searchBar.setVisible(show); + revalidate(); + repaint(); + if (show) { + searchField.getField().requestFocusInWindow(); + searchField.getField().selectAll(); + } else { + CodeEditor editor = getActiveEditor(); + if (editor != null) { + editor.getTextPane().requestFocusInWindow(); + } + } + } + + private void findNext() { + CodeEditor editor = getActiveEditor(); + if (editor == null) { + return; + } + String query = searchField.getText(); + if (query == null || query.isBlank()) { + return; + } + if (!editor.find(query, true)) { + UIManager.getLookAndFeel().provideErrorFeedback(this); + } + } + + private void gotoLineDialog() { + CodeEditor editor = getActiveEditor(); + if (editor == null) { + return; + } + String value = JOptionPane.showInputDialog(this, I18n.t("editor.lineNumber"), + I18n.t("editor.gotoLine"), JOptionPane.PLAIN_MESSAGE); + if (value == null || value.isBlank()) { + return; + } + try { + editor.goToLine(Integer.parseInt(value.trim())); + } catch (NumberFormatException ignored) { + } + } + + private void showEditorMenu(Component anchor) { + JPopupMenu menu = new JPopupMenu(); + menu.setBackground(Colors.PANEL_SECONDARY); + menu.setBorder(BorderFactory.createLineBorder(Colors.BORDER)); + menu.add(menuItem(I18n.t("editor.menu.goto"), e -> gotoLineDialog())); + menu.add(menuItem(I18n.t("editor.menu.selectAll"), e -> { + CodeEditor editor = getActiveEditor(); + if (editor != null) { + editor.getTextPane().selectAll(); + } + })); + menu.addSeparator(); + menu.add(menuItem(I18n.t("editor.menu.closeTab"), e -> closeActive())); + menu.show(anchor, 0, anchor.getHeight()); + } + + private JMenuItem menuItem(String text, java.awt.event.ActionListener listener) { + JMenuItem item = new JMenuItem(text); + item.setBackground(Colors.PANEL_SECONDARY); + item.setForeground(Colors.TEXT); + item.setFont(Fonts.BODY); + item.addActionListener(listener); + return item; + } + + private void updateEmptyState() { + if (editors.isEmpty()) { + cards.show(content, "__empty__"); + } + } + + private String displayLanguage(String language) { + return switch (language == null ? "" : language) { + case "smali" -> "Smali"; + case "xml" -> "XML"; + case "json" -> "JSON"; + case "hex" -> "Hex"; + case "java" -> "Java"; + default -> I18n.t("editor.language.text"); + }; + } + + private final class TabComponent extends JPanel { + private final SourceFile file; + private boolean active; + + private TabComponent(SourceFile file) { + super(new FlowLayout(FlowLayout.LEFT, 6, 0)); + this.file = file; + setOpaque(false); + setBorder(new EmptyBorder(6, 10, 6, 7)); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setToolTipText(file.getPath()); + + JLabel icon = new JLabel(IconFactory.icon(iconFor(file), 13, + active ? Colors.PRIMARY_BRIGHT : Colors.TEXT_MUTED)); + JLabel name = new JLabel(file.getName()); + name.setFont(Fonts.SMALL); + name.setForeground(Colors.TEXT_SECONDARY); + + JLabel close = new JLabel(IconFactory.icon("close", 11, Colors.TEXT_MUTED)); + close.setBorder(new EmptyBorder(0, 4, 0, 0)); + close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + close.setToolTipText(I18n.t("editor.closeTab", file.getName())); + close.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + close(file); + } + + @Override + public void mouseEntered(MouseEvent e) { + close.setIcon(IconFactory.icon("close", 11, Colors.ERROR)); + } + + @Override + public void mouseExited(MouseEvent e) { + close.setIcon(IconFactory.icon("close", 11, + active ? Colors.PRIMARY_BRIGHT : Colors.TEXT_MUTED)); + } + }); + + add(icon); + add(name); + add(close); + + MouseAdapter selectListener = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e)) { + select(TabComponent.this.file.getPath()); + } + } + }; + addMouseListener(selectListener); + icon.addMouseListener(selectListener); + name.addMouseListener(selectListener); + setActive(false); + } + + private String iconFor(SourceFile file) { + return switch (file.getLanguage()) { + case "xml" -> "file"; + case "hex" -> "archive"; + default -> "file-code"; + }; + } + + private void setActive(boolean active) { + this.active = active; + if (getComponentCount() >= 3) { + ((JLabel) getComponent(0)).setIcon(IconFactory.icon(iconFor(file), 13, + active ? Colors.PRIMARY_BRIGHT : Colors.TEXT_MUTED)); + ((JLabel) getComponent(1)).setForeground( + active ? Colors.PRIMARY_BRIGHT : Colors.TEXT_SECONDARY); + ((JLabel) getComponent(2)).setIcon(IconFactory.icon("close", 11, + active ? Colors.PRIMARY_BRIGHT : Colors.TEXT_MUTED)); + } + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + if (active) { + g2.setColor(Colors.PANEL_HOVER); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), 7, 7); + g2.setColor(Colors.alpha(Colors.PRIMARY, 100)); + g2.drawRoundRect(0, 0, getWidth() - 1, getHeight() - 1, 7, 7); + g2.setColor(Colors.PRIMARY_BRIGHT); + g2.fillRoundRect(8, getHeight() - 3, getWidth() - 16, 2, 2, 2); + } else { + g2.setColor(Colors.alpha(Colors.PANEL_HOVER, 60)); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), 7, 7); + } + } finally { + g2.dispose(); + } + super.paintComponent(g); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/editor/SmaliHighlighter.java b/src/main/java/com/reandroid/apkeditor/gui/editor/SmaliHighlighter.java new file mode 100644 index 00000000..ba218ceb --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/editor/SmaliHighlighter.java @@ -0,0 +1,277 @@ +package com.reandroid.apkeditor.gui.editor; + +import com.reandroid.apkeditor.gui.theme.Colors; + +import javax.swing.text.Style; +import javax.swing.text.StyleConstants; +import javax.swing.text.StyledDocument; +import java.awt.Font; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Resaltador de sintaxis real para Smali, XML, JSON y volcados hex. + * Trabaja sobre StyledDocument, por lo que el editor sigue siendo editable. + */ +public final class SmaliHighlighter { + private SmaliHighlighter() {} + + private static final Pattern DIRECTIVE = Pattern.compile("(?"); + private static final Pattern XML_TAG = Pattern.compile(" KEYWORDS = Set.of( + "nop", "move", "move-result", "move-exception", "return", "return-void", + "return-object", "return-wide", "const", "const-string", "const-class", + "const-wide", "const/4", "const/16", "const/high16", "const-wide/16", + "const-wide/32", "const-wide/high16", "monitor-enter", "monitor-exit", + "check-cast", "instance-of", "array-length", "new-instance", "new-array", + "filled-new-array", "filled-new-array/range", "fill-array-data", + "throw", "goto", "goto/16", "goto/32", "packed-switch", "sparse-switch", + "cmpl-float", "cmpg-float", "cmpl-double", "cmpg-double", "cmp-long", + "if-eq", "if-ne", "if-lt", "if-ge", "if-gt", "if-le", "if-eqz", "if-nez", + "if-ltz", "if-gez", "if-gtz", "if-lez", "aget", "aget-wide", "aget-object", + "aget-boolean", "aget-byte", "aget-char", "aget-short", "aput", "aput-wide", + "aput-object", "aput-boolean", "aput-byte", "aput-char", "aput-short", + "iget", "iget-wide", "iget-object", "iget-boolean", "iget-byte", "iget-char", + "iget-short", "iput", "iput-wide", "iput-object", "iput-boolean", "iput-byte", + "iput-char", "iput-short", "sget", "sget-wide", "sget-object", "sget-boolean", + "sget-byte", "sget-char", "sget-short", "sput", "sput-wide", "sput-object", + "sput-boolean", "sput-byte", "sput-char", "sput-short", "invoke-virtual", + "invoke-super", "invoke-direct", "invoke-static", "invoke-interface", + "invoke-virtual/range", "invoke-super/range", "invoke-direct/range", + "invoke-static/range", "invoke-interface/range", "invoke-polymorphic", + "invoke-custom", "invoke-custom/range", "neg-int", "not-int", "neg-long", + "not-long", "neg-float", "neg-double", "int-to-long", "int-to-float", + "int-to-double", "long-to-int", "long-to-float", "long-to-double", + "float-to-int", "float-to-long", "float-to-double", "double-to-int", + "double-to-long", "double-to-float", "int-to-byte", "int-to-char", + "int-to-short", "add-int", "sub-int", "mul-int", "div-int", "rem-int", + "and-int", "or-int", "xor-int", "shl-int", "shr-int", "ushr-int", + "add-long", "sub-long", "mul-long", "div-long", "rem-long", "and-long", + "or-long", "xor-long", "shl-long", "shr-long", "ushr-long", "add-float", + "sub-float", "mul-float", "div-float", "rem-float", "add-double", + "sub-double", "mul-double", "div-double", "rem-double", "new", "array", + "public", "private", "protected", "static", "final", "abstract", "native", + "synchronized", "volatile", "transient", "bridge", "synthetic", "enum", + "interface", "constructor", "strictfp", "varargs", "declared", "input", + "output", "subannotation", "annotation", "field", "method", "class", "super", + "source", "line", "locals", "registers", "param", "end", "prologue", + "epilogue", "restart", "enable", "disable", "catch", "array-data", + "packed-switch-data", "sparse-switch-data", "debug", "local", "end-local", + "true", "false", "null", "void", "boolean", "byte", "char", "short", "int", + "long", "float", "double", "object"); + + public static void highlight(javax.swing.JTextPane pane, String language) { + StyledDocument doc = pane.getStyledDocument(); + try { + int length = doc.getLength(); + if (length == 0) { + return; + } + String text = doc.getText(0, length); + ensureStyles(doc); + doc.setCharacterAttributes(0, length, doc.getStyle("base"), true); + switch (language == null ? "smali" : language) { + case "xml" -> highlightXml(doc, text); + case "json" -> highlightJson(doc, text); + case "hex" -> highlightHex(doc, text); + case "smali" -> highlightSmali(doc, text); + default -> { + } + } + } catch (Exception ignored) { + // Un error de resaltado no debe interrumpir la edición. + } + } + + private static void highlightSmali(StyledDocument doc, String text) { + int lineStart = 0; + while (lineStart <= text.length()) { + int lineEnd = text.indexOf('\n', lineStart); + if (lineEnd < 0) { + lineEnd = text.length(); + } + highlightSmaliLine(doc, text, lineStart, lineEnd); + lineStart = lineEnd + 1; + } + } + + private static void highlightSmaliLine(StyledDocument doc, String text, int start, int end) { + if (start >= end) { + return; + } + String line = text.substring(start, end); + int comment = indexOfComment(line); + int activeEnd = comment >= 0 ? comment : line.length(); + + apply(doc, line, start, activeEnd, DIRECTIVE, "directive"); + applyTokenKeywords(doc, line, start, activeEnd); + apply(doc, line, start, activeEnd, LABEL, "label"); + apply(doc, line, start, activeEnd, REGISTER, "register"); + apply(doc, line, start, activeEnd, NUMBER, "number"); + apply(doc, line, start, activeEnd, TYPE, "type"); + apply(doc, line, start, activeEnd, PRIMITIVE, "type"); + apply(doc, line, start, activeEnd, STRING, "string"); + + if (comment >= 0) { + int commentStart = start + comment; + doc.setCharacterAttributes(commentStart, end - commentStart, + doc.getStyle("comment"), true); + } + } + + private static int indexOfComment(String line) { + boolean inString = false; + boolean escaped = false; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\' && inString) { + escaped = true; + continue; + } + if (c == '"') { + inString = !inString; + } else if (c == '#' && !inString) { + return i; + } + } + return -1; + } + + private static void applyTokenKeywords(StyledDocument doc, String line, int start, int end) { + Matcher matcher = TOKEN.matcher(line); + while (matcher.find()) { + if (matcher.start() >= end) { + break; + } + if (matcher.end() > end) { + continue; + } + String token = matcher.group(); + if (KEYWORDS.contains(token)) { + doc.setCharacterAttributes(start + matcher.start(), + matcher.end() - matcher.start(), doc.getStyle("keyword"), true); + } + } + } + + private static void highlightXml(StyledDocument doc, String text) { + apply(doc, text, 0, text.length(), XML_TAG, "directive"); + apply(doc, text, 0, text.length(), XML_ATTRIBUTE, "type"); + apply(doc, text, 0, text.length(), STRING, "string"); + m(doc, text, XML_COMMENT, "comment"); + } + + private static void highlightJson(StyledDocument doc, String text) { + apply(doc, text, 0, text.length(), JSON_KEY, "directive"); + apply(doc, text, 0, text.length(), STRING, "string"); + apply(doc, text, 0, text.length(), NUMBER, "number"); + int lineStart = 0; + while (lineStart <= text.length()) { + int lineEnd = text.indexOf('\n', lineStart); + if (lineEnd < 0) { + lineEnd = text.length(); + } + String line = text.substring(lineStart, lineEnd); + Matcher matcher = Pattern.compile("\\b(true|false|null)\\b").matcher(line); + while (matcher.find()) { + doc.setCharacterAttributes(lineStart + matcher.start(), + matcher.end() - matcher.start(), doc.getStyle("keyword"), true); + } + lineStart = lineEnd + 1; + } + } + + private static void highlightHex(StyledDocument doc, String text) { + m(doc, text, HEX_COMMENT, "comment"); + Matcher matcher = Pattern.compile("\\b[0-9A-Fa-f]{2,8}\\b").matcher(text); + while (matcher.find()) { + doc.setCharacterAttributes(matcher.start(), + matcher.end() - matcher.start(), doc.getStyle("number"), true); + } + } + + private static void apply(StyledDocument doc, String text, int start, int end, + Pattern pattern, String style) { + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + if (matcher.start() >= end) { + break; + } + if (matcher.end() > end) { + continue; + } + doc.setCharacterAttributes(start + matcher.start(), + matcher.end() - matcher.start(), doc.getStyle(style), true); + } + } + + private static void m(StyledDocument doc, String text, Pattern pattern, String style) { + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + doc.setCharacterAttributes(matcher.start(), + matcher.end() - matcher.start(), doc.getStyle(style), true); + } + } + + private static void ensureStyles(StyledDocument doc) { + if (doc.getStyle("base") == null) { + Style base = doc.addStyle("base", null); + StyleConstants.setForeground(base, Colors.TEXT); + + Style comment = doc.addStyle("comment", base); + StyleConstants.setForeground(comment, Colors.TEXT_MUTED); + StyleConstants.setItalic(comment, true); + + Style directive = doc.addStyle("directive", base); + StyleConstants.setForeground(directive, Colors.CYAN); + StyleConstants.setBold(directive, true); + + Style keyword = doc.addStyle("keyword", base); + StyleConstants.setForeground(keyword, Colors.PRIMARY_BRIGHT); + + Style register = doc.addStyle("register", base); + StyleConstants.setForeground(register, Colors.MAGENTA); + + Style label = doc.addStyle("label", base); + StyleConstants.setForeground(label, Colors.PURPLE); + StyleConstants.setBold(label, true); + + Style string = doc.addStyle("string", base); + StyleConstants.setForeground(string, Colors.GREEN); + + Style number = doc.addStyle("number", base); + StyleConstants.setForeground(number, Colors.WARNING); + + Style type = doc.addStyle("type", base); + StyleConstants.setForeground(type, Colors.PRIMARY); + } else { + Style base = doc.getStyle("base"); + StyleConstants.setForeground(base, Colors.TEXT); + StyleConstants.setForeground(doc.getStyle("comment"), Colors.TEXT_MUTED); + StyleConstants.setForeground(doc.getStyle("directive"), Colors.CYAN); + StyleConstants.setForeground(doc.getStyle("keyword"), Colors.PRIMARY_BRIGHT); + StyleConstants.setForeground(doc.getStyle("register"), Colors.MAGENTA); + StyleConstants.setForeground(doc.getStyle("label"), Colors.PURPLE); + StyleConstants.setForeground(doc.getStyle("string"), Colors.GREEN); + StyleConstants.setForeground(doc.getStyle("number"), Colors.WARNING); + StyleConstants.setForeground(doc.getStyle("type"), Colors.PRIMARY); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/i18n/I18n.java b/src/main/java/com/reandroid/apkeditor/gui/i18n/I18n.java new file mode 100644 index 00000000..114be20c --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/i18n/I18n.java @@ -0,0 +1,104 @@ +package com.reandroid.apkeditor.gui.i18n; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +/** + * Traducciones de la interfaz. El bundle base contiene inglés y + * messages_es.properties contiene español. + */ +public final class I18n { + public enum Language { + SPANISH("es", "Español"), + ENGLISH("en", "English"); + + private final String code; + private final String label; + + Language(String code, String label) { + this.code = code; + this.label = label; + } + + public String getCode() { + return code; + } + + public String getLabel() { + return label; + } + + @Override + public String toString() { + return label; + } + } + + private static final String BUNDLE = "i18n.messages"; + private static final ResourceBundle.Control CONTROL = + ResourceBundle.Control.getNoFallbackControl( + ResourceBundle.Control.FORMAT_PROPERTIES); + private static Language language = Language.SPANISH; + private static ResourceBundle bundle = load(language); + + private I18n() { + } + + public static Language getLanguage() { + return language; + } + + public static String getLanguageCode() { + return language.getCode(); + } + + public static void setLanguage(String code) { + setLanguage(fromCode(code)); + } + + public static void setLanguage(Language selected) { + Language next = selected == null ? Language.SPANISH : selected; + if (next == language) { + return; + } + language = next; + bundle = load(next); + } + + public static Language fromCode(String code) { + if (code != null && code.toLowerCase(Locale.ROOT).startsWith("en")) { + return Language.ENGLISH; + } + return Language.SPANISH; + } + + public static String t(String key) { + if (key == null) { + return ""; + } + try { + return bundle.getString(key); + } catch (MissingResourceException error) { + return key; + } + } + + public static String t(String key, Object... arguments) { + String pattern = t(key); + if (arguments == null || arguments.length == 0) { + return pattern; + } + try { + return MessageFormat.format(pattern, arguments); + } catch (IllegalArgumentException error) { + return pattern; + } + } + + private static ResourceBundle load(Language selected) { + return ResourceBundle.getBundle(BUNDLE, + Locale.forLanguageTag(selected.getCode()), CONTROL); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/log/LogPanel.java b/src/main/java/com/reandroid/apkeditor/gui/log/LogPanel.java new file mode 100644 index 00000000..89a41799 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/log/LogPanel.java @@ -0,0 +1,168 @@ +package com.reandroid.apkeditor.gui.log; + +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.LogEntry; +import com.reandroid.apkeditor.gui.model.LogLevel; +import com.reandroid.apkeditor.gui.service.LogService; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.text.*; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * Panel de log con timestamps, niveles coloreados, auto-scroll, + * limpieza y exportación a archivo. + */ +public final class LogPanel extends PanelCard implements LogService.Listener { + private final LogService logService; + private final JTextPane textPane = new JTextPane(); + private final StyledDocument document; + private final JFileChooser chooser = new JFileChooser(); + private final JScrollPane scroll; + + public LogPanel(LogService logService) { + super(I18n.t("log.panelTitle"), "terminal"); + this.logService = logService; + setSubtitle(I18n.t("log.panelSubtitle")); + + textPane.setEditable(false); + textPane.setFont(Fonts.MONO_SMALL); + textPane.setBackground(Colors.SURFACE); + textPane.setForeground(Colors.TEXT); + textPane.setBorder(new EmptyBorder(6, 8, 6, 8)); + textPane.setCaretColor(Colors.PRIMARY); + textPane.setSelectionColor(Colors.PANEL_HOVER); + this.document = textPane.getStyledDocument(); + ensureStyles(); + + scroll = new JScrollPane(textPane); + scroll.setBorder(BorderFactory.createEmptyBorder()); + scroll.getViewport().setBackground(Colors.SURFACE); + scroll.getVerticalScrollBar().setUnitIncrement(16); + + NeonButton clear = new NeonButton(I18n.t("log.clear"), + IconFactory.icon("trash", 13, Colors.TEXT_SECONDARY)); + clear.setVariant(NeonButton.Variant.GHOST); + clear.setFont(Fonts.SMALL); + clear.setCompact(true); + clear.addActionListener(e -> logService.clear()); + + NeonButton export = new NeonButton(I18n.t("log.export"), + IconFactory.icon("download", 13, Colors.TEXT_SECONDARY)); + export.setVariant(NeonButton.Variant.GHOST); + export.setFont(Fonts.SMALL); + export.setCompact(true); + export.addActionListener(e -> exportLogs()); + + addHeaderAction(clear); + addHeaderAction(export); + + getBody().setLayout(new BorderLayout()); + getBody().add(scroll, BorderLayout.CENTER); + + List entries = logService.getEntries(); + for (LogEntry entry : entries) { + appendEntry(entry); + } + logService.addListener(this); + scrollToBottom(); + } + + @Override + public void onEntry(LogEntry entry) { + appendEntry(entry); + scrollToBottom(); + } + + @Override + public void onCleared() { + textPane.setText(""); + } + + private void appendEntry(LogEntry entry) { + try { + int start = document.getLength(); + document.insertString(start, "[" + entry.getTimestamp() + "] ", + document.getStyle("timestamp")); + document.insertString(document.getLength(), "● ", styleFor(entry.getLevel())); + document.insertString(document.getLength(), entry.getMessage() + "\n", + styleFor(entry.getLevel())); + } catch (BadLocationException ignored) { + } + } + + private Style styleFor(LogLevel level) { + String name = switch (level) { + case SUCCESS -> "success"; + case WARNING -> "warning"; + case ERROR -> "error"; + case COMMAND -> "command"; + case DEBUG -> "debug"; + default -> "info"; + }; + return document.getStyle(name); + } + + private void ensureStyles() { + Style base = document.addStyle("base", null); + StyleConstants.setForeground(base, Colors.TEXT); + StyleConstants.setFontFamily(base, Fonts.MONO_SMALL.getFamily()); + StyleConstants.setFontSize(base, Fonts.MONO_SMALL.getSize()); + + Style timestamp = document.addStyle("timestamp", base); + StyleConstants.setForeground(timestamp, Colors.TEXT_MUTED); + + Style info = document.addStyle("info", base); + StyleConstants.setForeground(info, Colors.TEXT); + + Style success = document.addStyle("success", base); + StyleConstants.setForeground(success, Colors.SUCCESS); + + Style warning = document.addStyle("warning", base); + StyleConstants.setForeground(warning, Colors.WARNING); + + Style error = document.addStyle("error", base); + StyleConstants.setForeground(error, Colors.ERROR); + + Style command = document.addStyle("command", base); + StyleConstants.setForeground(command, Colors.CYAN); + + Style debug = document.addStyle("debug", base); + StyleConstants.setForeground(debug, Colors.TEXT_MUTED); + } + + private void scrollToBottom() { + SwingUtilities.invokeLater(() -> { + textPane.setCaretPosition(document.getLength()); + }); + } + + @Override + public void removeNotify() { + logService.removeListener(this); + super.removeNotify(); + } + + private void exportLogs() { + chooser.setSelectedFile(new File("apkeditor-log.txt")); + if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) { + return; + } + File file = chooser.getSelectedFile(); + try { + logService.export(file); + logService.success(I18n.t("log.exported", file.getAbsolutePath())); + } catch (IOException error) { + logService.error(I18n.t("log.exportError", error.getMessage())); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/model/ApkAnalysis.java b/src/main/java/com/reandroid/apkeditor/gui/model/ApkAnalysis.java new file mode 100644 index 00000000..8785f7a7 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/model/ApkAnalysis.java @@ -0,0 +1,142 @@ +package com.reandroid.apkeditor.gui.model; + +import com.reandroid.apkeditor.gui.i18n.I18n; + +public final class ApkAnalysis { + private String appName = "MyApplication"; + private String packageName = "com.example.myapp"; + private String versionName = "1.0.0"; + private int versionCode = 1; + private int minSdk = 21; + private int targetSdk = 34; + private int smaliFiles = 342; + private int classes = 128; + private int resources = 56; + private int permissions = 8; + private int components = 5; + private int fileCount = 186; + private long apkSize = 0L; + + public static ApkAnalysis demo() { + return new ApkAnalysis(); + } + + public String getAppName() { + return appName; + } + + public void setAppName(String appName) { + this.appName = valueOr(appName, I18n.t("common.unnamed")); + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = valueOr(packageName, "—"); + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = valueOr(versionName, "—"); + } + + public int getVersionCode() { + return versionCode; + } + + public void setVersionCode(int versionCode) { + this.versionCode = versionCode; + } + + public int getMinSdk() { + return minSdk; + } + + public void setMinSdk(int minSdk) { + this.minSdk = minSdk; + } + + public int getTargetSdk() { + return targetSdk; + } + + public void setTargetSdk(int targetSdk) { + this.targetSdk = targetSdk; + } + + public int getSmaliFiles() { + return smaliFiles; + } + + public void setSmaliFiles(int smaliFiles) { + this.smaliFiles = smaliFiles; + } + + public int getClasses() { + return classes; + } + + public void setClasses(int classes) { + this.classes = classes; + } + + public int getResources() { + return resources; + } + + public void setResources(int resources) { + this.resources = resources; + } + + public int getPermissions() { + return permissions; + } + + public void setPermissions(int permissions) { + this.permissions = permissions; + } + + public int getComponents() { + return components; + } + + public void setComponents(int components) { + this.components = components; + } + + public int getFileCount() { + return fileCount; + } + + public void setFileCount(int fileCount) { + this.fileCount = fileCount; + } + + public long getApkSize() { + return apkSize; + } + + public void setApkSize(long apkSize) { + this.apkSize = apkSize; + } + + public String getSizeLabel() { + if (apkSize <= 0) { + return "—"; + } + double mb = apkSize / (1024.0 * 1024.0); + if (mb >= 1) { + return String.format("%.1f MB", mb); + } + return String.format("%.0f KB", apkSize / 1024.0); + } + + private static String valueOr(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/model/AppState.java b/src/main/java/com/reandroid/apkeditor/gui/model/AppState.java new file mode 100644 index 00000000..51789dde --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/model/AppState.java @@ -0,0 +1,132 @@ +package com.reandroid.apkeditor.gui.model; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Estado compartido de la interfaz. No contiene lógica del motor APK. + */ +public final class AppState { + public static final String INPUT_CHANGED = "input"; + public static final String OUTPUT_CHANGED = "output"; + public static final String ROUTE_CHANGED = "route"; + public static final String BUSY_CHANGED = "busy"; + public static final String ANALYSIS_CHANGED = "analysis"; + public static final String THEME_CHANGED = "theme"; + public static final String LANGUAGE_CHANGED = "language"; + public static final String FILES_CHANGED = "files"; + public static final String TREE_CHANGED = "tree"; + + private final PropertyChangeSupport changes = new PropertyChangeSupport(this); + + private File currentInput; + private File currentOutput; + private String activeRoute = "dashboard"; + private boolean busy; + private String themeName = "Futuristic"; + private String languageCode = "es"; + private ApkAnalysis analysis = ApkAnalysis.demo(); + private FileNode currentTree; + private final List openFiles = new ArrayList<>(); + private String selectedFilePath; + + public File getCurrentInput() { return currentInput; } + public void setCurrentInput(File currentInput) { + File old = this.currentInput; + this.currentInput = currentInput; + changes.firePropertyChange(INPUT_CHANGED, old, currentInput); + } + + public File getCurrentOutput() { return currentOutput; } + public void setCurrentOutput(File currentOutput) { + File old = this.currentOutput; + this.currentOutput = currentOutput; + changes.firePropertyChange(OUTPUT_CHANGED, old, currentOutput); + } + + public String getActiveRoute() { return activeRoute; } + public void setActiveRoute(String activeRoute) { + String old = this.activeRoute; + this.activeRoute = activeRoute; + changes.firePropertyChange(ROUTE_CHANGED, old, activeRoute); + } + + public boolean isBusy() { return busy; } + public void setBusy(boolean busy) { + boolean old = this.busy; + this.busy = busy; + changes.firePropertyChange(BUSY_CHANGED, old, busy); + } + + public String getThemeName() { return themeName; } + public void setThemeName(String themeName) { + String old = this.themeName; + this.themeName = themeName; + changes.firePropertyChange(THEME_CHANGED, old, themeName); + } + + public String getLanguageCode() { return languageCode; } + public void setLanguageCode(String languageCode) { + String old = this.languageCode; + this.languageCode = languageCode == null ? "es" : languageCode; + changes.firePropertyChange(LANGUAGE_CHANGED, old, this.languageCode); + } + + public ApkAnalysis getAnalysis() { return analysis; } + public void setAnalysis(ApkAnalysis analysis) { + ApkAnalysis old = this.analysis; + this.analysis = analysis == null ? ApkAnalysis.demo() : analysis; + changes.firePropertyChange(ANALYSIS_CHANGED, old, this.analysis); + } + + public FileNode getCurrentTree() { + return currentTree; + } + + public void setCurrentTree(FileNode currentTree) { + FileNode old = this.currentTree; + this.currentTree = currentTree; + changes.firePropertyChange(TREE_CHANGED, old, currentTree); + } + + public List getOpenFiles() { + return new ArrayList<>(openFiles); + } + + public void setOpenFiles(List files) { + openFiles.clear(); + if (files != null) { + openFiles.addAll(files); + } + changes.firePropertyChange(FILES_CHANGED, null, null); + } + + public void addOpenFile(SourceFile file) { + if (file == null) { + return; + } + openFiles.remove(file); + openFiles.add(file); + changes.firePropertyChange(FILES_CHANGED, null, file); + } + + public String getSelectedFilePath() { return selectedFilePath; } + public void setSelectedFilePath(String selectedFilePath) { + this.selectedFilePath = selectedFilePath; + } + + public void addPropertyChangeListener(PropertyChangeListener listener) { + changes.addPropertyChangeListener(listener); + } + + public void addPropertyChangeListener(String property, PropertyChangeListener listener) { + changes.addPropertyChangeListener(property, listener); + } + + public void removePropertyChangeListener(PropertyChangeListener listener) { + changes.removePropertyChangeListener(listener); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/model/FileNode.java b/src/main/java/com/reandroid/apkeditor/gui/model/FileNode.java new file mode 100644 index 00000000..79840bf0 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/model/FileNode.java @@ -0,0 +1,133 @@ +package com.reandroid.apkeditor.gui.model; + +import javax.swing.tree.DefaultMutableTreeNode; +import java.util.ArrayList; +import java.util.List; + +/** + * Nodo del explorador de archivos. Puede representar un archivo real, + * una entrada ZIP o un elemento de la estructura de demostración. + */ +public final class FileNode extends DefaultMutableTreeNode { + public enum Type { + ARCHIVE, FOLDER, FILE, SMALI, XML, DEX, RESOURCE, IMAGE, LIBRARY, MANIFEST, ASSETS + } + + private final String name; + private final Type type; + private String sourcePath; + private String content; + private long size; + private boolean binary; + private boolean loaded; + + public FileNode(String name, Type type) { + super(name, true); + this.name = name; + this.type = type; + } + + public String getName() { + return name; + } + + public Type getType() { + return type; + } + + public String getSourcePath() { + return sourcePath; + } + + public void setSourcePath(String sourcePath) { + this.sourcePath = sourcePath; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + this.loaded = content != null; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + public boolean isBinary() { + return binary; + } + + public void setBinary(boolean binary) { + this.binary = binary; + } + + public boolean isLoaded() { + return loaded; + } + + public boolean isContainer() { + return type == Type.ARCHIVE || type == Type.FOLDER; + } + + @SuppressWarnings("unchecked") + public List childNodes() { + List result = new ArrayList<>(); + for (int i = 0; i < getChildCount(); i++) { + result.add((FileNode) getChildAt(i)); + } + return result; + } + + public FileNode shallowCopy() { + FileNode copy = new FileNode(name, type); + copy.sourcePath = sourcePath; + copy.content = content; + copy.size = size; + copy.binary = binary; + copy.loaded = loaded; + return copy; + } + + public FileNode add(String childName, Type childType) { + FileNode child = new FileNode(childName, childType); + add(child); + return child; + } + + public FileNode findChild(String childName) { + for (int i = 0; i < getChildCount(); i++) { + FileNode node = (FileNode) getChildAt(i); + if (node.name.equals(childName)) { + return node; + } + } + return null; + } + + public String buildPath() { + if (sourcePath != null && !sourcePath.isBlank()) { + return sourcePath; + } + StringBuilder builder = new StringBuilder(name); + Object[] path = getUserObjectPath(); + for (int i = 1; i < path.length; i++) { + Object value = path[i]; + if (value instanceof FileNode node && node != this) { + builder.insert(0, node.name + "/"); + } + } + return builder.toString(); + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/model/LogEntry.java b/src/main/java/com/reandroid/apkeditor/gui/model/LogEntry.java new file mode 100644 index 00000000..53149f20 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/model/LogEntry.java @@ -0,0 +1,42 @@ +package com.reandroid.apkeditor.gui.model; + +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +public final class LogEntry { + private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss"); + + private final LocalTime time; + private final LogLevel level; + private final String message; + + public LogEntry(LogLevel level, String message) { + this(LocalTime.now(), level, message); + } + + public LogEntry(LocalTime time, LogLevel level, String message) { + this.time = time; + this.level = level; + this.message = message; + } + + public LocalTime getTime() { + return time; + } + + public LogLevel getLevel() { + return level; + } + + public String getMessage() { + return message; + } + + public String getTimestamp() { + return FORMAT.format(time); + } + + public String toPlainText() { + return "[" + getTimestamp() + "] [" + level.name() + "] " + message; + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/model/LogLevel.java b/src/main/java/com/reandroid/apkeditor/gui/model/LogLevel.java new file mode 100644 index 00000000..3ccb5163 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/model/LogLevel.java @@ -0,0 +1,10 @@ +package com.reandroid.apkeditor.gui.model; + +public enum LogLevel { + COMMAND, + INFO, + SUCCESS, + WARNING, + ERROR, + DEBUG +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/model/SourceFile.java b/src/main/java/com/reandroid/apkeditor/gui/model/SourceFile.java new file mode 100644 index 00000000..a4f681e2 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/model/SourceFile.java @@ -0,0 +1,55 @@ +package com.reandroid.apkeditor.gui.model; + +import java.util.Objects; + +/** + * Documento abierto en el editor. Vive en la capa de modelo de la UI, + * nunca ejecuta operaciones del motor. + */ +public final class SourceFile { + private final String name; + private final String path; + private final String content; + private final String language; + private final boolean binary; + + public SourceFile(String name, String path, String content, String language, boolean binary) { + this.name = name; + this.path = path == null ? name : path; + this.content = content == null ? "" : content; + this.language = language == null ? "smali" : language; + this.binary = binary; + } + + public String getName() { + return name; + } + + public String getPath() { + return path; + } + + public String getContent() { + return content; + } + + public String getLanguage() { + return language; + } + + public boolean isBinary() { + return binary; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SourceFile that)) return false; + return Objects.equals(path, that.path); + } + + @Override + public int hashCode() { + return Objects.hash(path); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/navigation/Navigator.java b/src/main/java/com/reandroid/apkeditor/gui/navigation/Navigator.java new file mode 100644 index 00000000..60052506 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/navigation/Navigator.java @@ -0,0 +1,18 @@ +package com.reandroid.apkeditor.gui.navigation; + +import java.awt.CardLayout; +import java.awt.Container; + +public final class Navigator { + private final Container container; + private final CardLayout layout; + + public Navigator(Container container, CardLayout layout) { + this.container = container; + this.layout = layout; + } + + public void show(Route route) { + layout.show(container, route.name()); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/navigation/Route.java b/src/main/java/com/reandroid/apkeditor/gui/navigation/Route.java new file mode 100644 index 00000000..fd632fe8 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/navigation/Route.java @@ -0,0 +1,16 @@ +package com.reandroid.apkeditor.gui.navigation; + +public enum Route { + DASHBOARD, + DECOMPILE, + ANALYZE, + SMALI_VIEWER, + BUILD, + MERGE, + REFACTOR, + PROTECT, + INFORMATION, + LOGS, + SETTINGS, + DECODE +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/panel/AnalysisProgressPanel.java b/src/main/java/com/reandroid/apkeditor/gui/panel/AnalysisProgressPanel.java new file mode 100644 index 00000000..9e3a9f5b --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/panel/AnalysisProgressPanel.java @@ -0,0 +1,142 @@ +package com.reandroid.apkeditor.gui.panel; + +import com.reandroid.apkeditor.gui.components.NeonProgressBar; +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.components.StatusDot; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Panel inferior de progreso con descripción y etapas reales. + */ +public final class AnalysisProgressPanel extends PanelCard { + public enum StepStatus { COMPLETED, ACTIVE, PENDING } + + private final JLabel description = new JLabel(); + private final NeonProgressBar progress = new NeonProgressBar(); + private final JLabel stateLabel = new JLabel(I18n.t("progress.state.running")); + private final StatusDot stateDot = new StatusDot(Colors.WARNING); + private final List steps = new ArrayList<>(); + private final JPanel stepsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 14, 0)); + + public AnalysisProgressPanel(String[] stepNames) { + super(I18n.t("progress.title"), "workflow"); + setSubtitle(I18n.t("progress.subtitle")); + + description.setText(I18n.t("progress.description")); + description.setFont(Fonts.SMALL); + description.setForeground(Colors.TEXT_SECONDARY); + + progress.setValue(68); + progress.setPreferredSize(new Dimension(240, 18)); + + stateLabel.setFont(Fonts.TINY); + stateLabel.setForeground(Colors.WARNING); + JPanel state = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0)); + state.setOpaque(false); + state.add(stateDot); + state.add(stateLabel); + addHeaderAction(state); + + stepsPanel.setOpaque(false); + if (stepNames != null) { + for (int i = 0; i < stepNames.length; i++) { + StepChip chip = new StepChip(stepNames[i]); + chip.setStatus(i < stepNames.length - 1 + ? StepStatus.COMPLETED : StepStatus.PENDING); + steps.add(chip); + stepsPanel.add(chip); + } + } + + JPanel body = getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + description.setAlignmentX(LEFT_ALIGNMENT); + progress.setAlignmentX(LEFT_ALIGNMENT); + stepsPanel.setAlignmentX(LEFT_ALIGNMENT); + body.add(description); + body.add(Box.createVerticalStrut(8)); + body.add(progress); + body.add(Box.createVerticalStrut(10)); + body.add(stepsPanel); + } + + public void setProgress(int value) { + progress.setValue(value); + } + + public int getProgress() { + return progress.getValue(); + } + + public void setDescription(String text) { + description.setText(text); + } + + public void setState(String text, Color color) { + stateLabel.setText(text); + stateLabel.setForeground(color); + stateDot.setColor(color); + } + + public void setStepStatus(int index, StepStatus status) { + if (index >= 0 && index < steps.size()) { + steps.get(index).setStatus(status); + } + } + + public void resetForRun() { + setProgress(0); + setState(I18n.t("progress.state.running"), Colors.WARNING); + for (int i = 0; i < steps.size(); i++) { + steps.get(i).setStatus(i == 0 ? StepStatus.ACTIVE : StepStatus.PENDING); + } + } + + public void finish(boolean success) { + setProgress(success ? 100 : 0); + setState(I18n.t(success ? "progress.state.completed" : "progress.state.failed"), + success ? Colors.SUCCESS : Colors.ERROR); + for (StepChip step : steps) { + step.setStatus(success ? StepStatus.COMPLETED : StepStatus.PENDING); + } + } + + private static final class StepChip extends JPanel { + private final JLabel icon = new JLabel(); + private final JLabel label = new JLabel(); + private StepStatus status = StepStatus.PENDING; + + private StepChip(String text) { + super(new FlowLayout(FlowLayout.LEFT, 5, 0)); + setOpaque(false); + label.setText(text); + label.setFont(Fonts.TINY); + add(icon); + add(label); + setStatus(status); + } + + private void setStatus(StepStatus status) { + this.status = status; + Color color = switch (status) { + case COMPLETED -> Colors.SUCCESS; + case ACTIVE -> Colors.PRIMARY_BRIGHT; + default -> Colors.TEXT_MUTED; + }; + icon.setIcon(IconFactory.icon(status == StepStatus.COMPLETED ? "check" : "clock", + 12, color)); + label.setForeground(color); + revalidate(); + repaint(); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/panel/AppStatusBar.java b/src/main/java/com/reandroid/apkeditor/gui/panel/AppStatusBar.java new file mode 100644 index 00000000..d9d797ff --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/panel/AppStatusBar.java @@ -0,0 +1,115 @@ +package com.reandroid.apkeditor.gui.panel; + +import com.reandroid.apkeditor.gui.components.NeonProgressBar; +import com.reandroid.apkeditor.gui.components.StatusDot; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; + +import javax.swing.*; +import java.awt.*; + +/** + * Barra de estado global con memoria y mensajes del sistema. + */ +public final class AppStatusBar extends JPanel { + private final AppState state; + private final JLabel system = new JLabel(I18n.t("status.ready")); + private final StatusDot dot = new StatusDot(Colors.SUCCESS); + private final JLabel memory = new JLabel(I18n.t("status.memory", "—")); + private final NeonProgressBar memoryBar = new NeonProgressBar(); + private final Timer timer; + + public AppStatusBar(AppState state) { + super(new BorderLayout(14, 0)); + this.state = state; + setPreferredSize(new Dimension(0, 48)); + setMinimumSize(new Dimension(0, 48)); + setOpaque(true); + setBackground(Colors.BACKGROUND_SECONDARY); + setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, Colors.BORDER), + BorderFactory.createEmptyBorder(0, 18, 0, 18))); + + system.setFont(Fonts.SMALL); + system.setForeground(Colors.SUCCESS); + + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 7, 0)); + left.setOpaque(false); + left.add(dot); + left.add(system); + left.add(separator()); + memory.setFont(Fonts.SMALL); + memory.setForeground(Colors.TEXT_MUTED); + left.add(memory); + + memoryBar.setShowPercentage(false); + memoryBar.setAnimated(false); + memoryBar.setPreferredSize(new Dimension(130, 12)); + memoryBar.setMinimumSize(new Dimension(90, 12)); + memoryBar.setAccent(Colors.SUCCESS); + left.add(memoryBar); + + JLabel footer = new JLabel(I18n.t("status.footer")); + footer.setFont(Fonts.TINY.deriveFont(Font.BOLD)); + footer.setForeground(Colors.TEXT_MUTED); + + add(left, BorderLayout.WEST); + add(footer, BorderLayout.EAST); + + state.addPropertyChangeListener(AppState.BUSY_CHANGED, e -> + SwingUtilities.invokeLater(() -> setBusy((Boolean) e.getNewValue()))); + + timer = new Timer(2500, e -> updateMemory()); + timer.start(); + updateMemory(); + } + + private JLabel separator() { + JLabel label = new JLabel("│"); + label.setForeground(Colors.BORDER); + label.setFont(Fonts.SMALL); + return label; + } + + public void setBusy(boolean busy) { + if (busy) { + system.setText(I18n.t("status.processing")); + system.setForeground(Colors.WARNING); + dot.setColor(Colors.WARNING); + } else { + system.setText(I18n.t("status.ready")); + system.setForeground(Colors.SUCCESS); + dot.setColor(Colors.SUCCESS); + } + } + + public void setSystemStatus(String text, Color color) { + system.setText(text); + system.setForeground(color); + dot.setColor(color); + } + + private void updateMemory() { + Runtime runtime = Runtime.getRuntime(); + long used = runtime.totalMemory() - runtime.freeMemory(); + long max = runtime.maxMemory(); + memory.setText(I18n.t("status.memory", format(used) + " / " + format(max))); + memoryBar.setValue(max <= 0 ? 0 : (int) Math.min(100, used * 100 / max)); + } + + private String format(long bytes) { + double mb = bytes / (1024.0 * 1024.0); + if (mb >= 1024) { + return String.format("%.1f GB", mb / 1024.0); + } + return String.format("%.0f MB", mb); + } + + @Override + public void removeNotify() { + timer.stop(); + super.removeNotify(); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/panel/ApplicationInfoPanel.java b/src/main/java/com/reandroid/apkeditor/gui/panel/ApplicationInfoPanel.java new file mode 100644 index 00000000..d78478f0 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/panel/ApplicationInfoPanel.java @@ -0,0 +1,73 @@ +package com.reandroid.apkeditor.gui.panel; + +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.ApkAnalysis; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; + +import javax.swing.*; +import java.awt.*; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Información del APK cargado. Los valores se actualizan desde el modelo, + * no están hardcodeados en el componente. + */ +public final class ApplicationInfoPanel extends PanelCard { + private final Map values = new LinkedHashMap<>(); + + public ApplicationInfoPanel() { + super(I18n.t("applicationInfo.title"), "android"); + setSubtitle(I18n.t("applicationInfo.subtitle")); + + JPanel body = getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + addRow(body, I18n.t("applicationInfo.name")); + addRow(body, I18n.t("applicationInfo.version")); + addRow(body, I18n.t("applicationInfo.package")); + addRow(body, I18n.t("applicationInfo.minSdk")); + addRow(body, I18n.t("applicationInfo.targetSdk")); + setAnalysis(ApkAnalysis.demo()); + } + + private void addRow(JPanel body, String labelText) { + JPanel row = new JPanel(new BorderLayout(8, 0)); + row.setOpaque(false); + row.setBorder(BorderFactory.createEmptyBorder(2, 0, 2, 0)); + + JLabel label = new JLabel(labelText); + label.setFont(Fonts.SMALL); + label.setForeground(Colors.TEXT_MUTED); + + JLabel value = new JLabel("—"); + value.setFont(Fonts.BODY_MEDIUM); + value.setForeground(Colors.TEXT); + value.setHorizontalAlignment(SwingConstants.RIGHT); + + row.add(label, BorderLayout.WEST); + row.add(value, BorderLayout.CENTER); + values.put(labelText, value); + body.add(row); + } + + public void setAnalysis(ApkAnalysis analysis) { + if (analysis == null) { + return; + } + setValue(I18n.t("applicationInfo.name"), analysis.getAppName()); + setValue(I18n.t("applicationInfo.version"), analysis.getVersionName()); + setValue(I18n.t("applicationInfo.package"), analysis.getPackageName()); + setValue(I18n.t("applicationInfo.minSdk"), String.valueOf(analysis.getMinSdk())); + setValue(I18n.t("applicationInfo.targetSdk"), String.valueOf(analysis.getTargetSdk())); + } + + private void setValue(String key, String value) { + JLabel label = values.get(key); + if (label != null) { + label.setText(value == null || value.isBlank() ? "—" : value); + label.setToolTipText(label.getText()); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/panel/BottomDock.java b/src/main/java/com/reandroid/apkeditor/gui/panel/BottomDock.java new file mode 100644 index 00000000..fc303698 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/panel/BottomDock.java @@ -0,0 +1,66 @@ +package com.reandroid.apkeditor.gui.panel; + +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.ApkAnalysis; +import com.reandroid.apkeditor.gui.theme.Colors; + +import javax.swing.*; +import java.awt.*; + +/** + * Fila inferior de paneles: progreso, información y estadísticas. + */ +public final class BottomDock extends JPanel { + private final AnalysisProgressPanel progress; + private final ApplicationInfoPanel information; + private final StatisticsPanel statistics; + + public BottomDock() { + super(new GridBagLayout()); + setOpaque(false); + + progress = new AnalysisProgressPanel(new String[]{ + I18n.t("progress.step.loading"), + I18n.t("progress.step.decompiling"), + I18n.t("progress.step.analysis"), + I18n.t("progress.step.report")}); + information = new ApplicationInfoPanel(); + statistics = new StatisticsPanel(); + + GridBagConstraints c = new GridBagConstraints(); + c.gridy = 0; + c.fill = GridBagConstraints.BOTH; + c.weighty = 1.0; + c.insets = new Insets(0, 0, 0, 14); + + c.gridx = 0; + c.weightx = 0.55; + add(progress, c); + c.gridx = 1; + c.weightx = 0.25; + add(information, c); + c.gridx = 2; + c.weightx = 0.20; + c.insets = new Insets(0, 0, 0, 0); + add(statistics, c); + + setBackground(Colors.BACKGROUND); + } + + public AnalysisProgressPanel getProgressPanel() { + return progress; + } + + public ApplicationInfoPanel getInformationPanel() { + return information; + } + + public StatisticsPanel getStatisticsPanel() { + return statistics; + } + + public void setAnalysis(ApkAnalysis analysis) { + information.setAnalysis(analysis); + statistics.setAnalysis(analysis); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/panel/StatisticsPanel.java b/src/main/java/com/reandroid/apkeditor/gui/panel/StatisticsPanel.java new file mode 100644 index 00000000..f836a127 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/panel/StatisticsPanel.java @@ -0,0 +1,78 @@ +package com.reandroid.apkeditor.gui.panel; + +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.ApkAnalysis; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import java.awt.*; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Estadísticas del APK cargado. Componente reutilizable en dashboard e info. + */ +public final class StatisticsPanel extends PanelCard { + private final Map values = new LinkedHashMap<>(); + + public StatisticsPanel() { + super(I18n.t("statistics.title"), "binary"); + setSubtitle(I18n.t("statistics.subtitle")); + + JPanel body = getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + addRow(body, I18n.t("statistics.smaliFiles"), "file-code", Colors.PRIMARY); + addRow(body, I18n.t("statistics.classes"), "layers", Colors.CYAN); + addRow(body, I18n.t("statistics.resources"), "folder", Colors.WARNING); + addRow(body, I18n.t("statistics.permissions"), "shield", Colors.SUCCESS); + addRow(body, I18n.t("statistics.components"), "blocks", Colors.PURPLE); + setAnalysis(ApkAnalysis.demo()); + } + + private void addRow(JPanel body, String labelText, String icon, Color color) { + JPanel row = new JPanel(new BorderLayout(6, 0)); + row.setOpaque(false); + row.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0)); + + JLabel iconLabel = new JLabel(IconFactory.icon(icon, 13, color)); + JLabel label = new JLabel(labelText); + label.setFont(Fonts.SMALL); + label.setForeground(Colors.TEXT_SECONDARY); + + JLabel value = new JLabel("0"); + value.setFont(Fonts.BODY_MEDIUM); + value.setForeground(Colors.TEXT); + value.setHorizontalAlignment(SwingConstants.RIGHT); + + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + left.setOpaque(false); + left.add(iconLabel); + left.add(label); + + row.add(left, BorderLayout.WEST); + row.add(value, BorderLayout.CENTER); + values.put(labelText, value); + body.add(row); + } + + public void setAnalysis(ApkAnalysis analysis) { + if (analysis == null) { + return; + } + setValue(I18n.t("statistics.smaliFiles"), analysis.getSmaliFiles()); + setValue(I18n.t("statistics.classes"), analysis.getClasses()); + setValue(I18n.t("statistics.resources"), analysis.getResources()); + setValue(I18n.t("statistics.permissions"), analysis.getPermissions()); + setValue(I18n.t("statistics.components"), analysis.getComponents()); + } + + private void setValue(String key, int value) { + JLabel label = values.get(key); + if (label != null) { + label.setText(String.valueOf(value)); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/service/ApkService.java b/src/main/java/com/reandroid/apkeditor/gui/service/ApkService.java new file mode 100644 index 00000000..4c5c8c97 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/service/ApkService.java @@ -0,0 +1,515 @@ +package com.reandroid.apkeditor.gui.service; + +import com.reandroid.apk.ApkModule; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.ApkAnalysis; +import com.reandroid.apkeditor.gui.model.FileNode; +import com.reandroid.apkeditor.gui.model.SourceFile; +import com.reandroid.arsc.chunk.xml.AndroidManifestBlock; +import com.reandroid.dex.id.ClassId; +import com.reandroid.dex.model.DexDirectory; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Acceso de sólo lectura a paquetes APK y directorios descompilados. + * Se ejecuta fuera del EDT y no depende de ningún componente visual. + */ +public final class ApkService { + private ApkService() {} + + public static final class Result { + private final ApkAnalysis analysis; + private final FileNode tree; + private final List messages; + + public Result(ApkAnalysis analysis, FileNode tree, List messages) { + this.analysis = analysis; + this.tree = tree; + this.messages = messages; + } + + public ApkAnalysis getAnalysis() { + return analysis; + } + + public FileNode getTree() { + return tree; + } + + public List getMessages() { + return messages; + } + } + + public static Result analyze(File file) throws IOException { + List messages = new ArrayList<>(); + ApkAnalysis analysis = ApkAnalysis.demo(); + FileNode tree; + if (file == null) { + tree = SampleProject.buildTree(); + return new Result(analysis, tree, messages); + } + if (!file.exists()) { + throw new IOException(I18n.t("apk.error.notFound", file.getAbsolutePath())); + } + analysis.setAppName(stripExtension(file.getName())); + analysis.setApkSize(file.isFile() ? file.length() : directorySize(file)); + + if (file.isDirectory()) { + tree = buildDirectoryTree(file); + analyzeDirectory(file, analysis, messages); + return new Result(analysis, tree, messages); + } + + tree = buildZipTree(file, messages); + analyzeZip(file, analysis, messages); + return new Result(analysis, tree, messages); + } + + public static SourceFile read(File source, String path) throws IOException { + if (source == null || path == null) { + return null; + } + String name = path; + int slash = path.lastIndexOf('/'); + if (slash >= 0) { + name = path.substring(slash + 1); + } + if (source.isDirectory()) { + File file = new File(source, path); + if (!file.exists()) { + return null; + } + byte[] bytes = java.nio.file.Files.readAllBytes(file.toPath()); + return toSourceFile(name, path, bytes); + } + if (!isZip(source)) { + byte[] bytes = java.nio.file.Files.readAllBytes(source.toPath()); + return toSourceFile(name, path, bytes); + } + try (ZipFile zip = new ZipFile(source)) { + ZipEntry entry = zip.getEntry(path); + if (entry == null) { + return null; + } + try (InputStream input = zip.getInputStream(entry)) { + byte[] bytes = readLimited(input, 2 * 1024 * 1024); + return toSourceFile(name, path, bytes); + } + } + } + + private static SourceFile toSourceFile(String name, String path, byte[] bytes) { + boolean binary = isBinaryName(name) || containsNul(bytes); + if (binary) { + String hex = hexDump(bytes, 4096); + String content = I18n.t("apk.hexHeader") + "\n" + + "; " + path + "\n\n" + hex; + return new SourceFile(name, path, content, "hex", true); + } + String content = new String(bytes, StandardCharsets.UTF_8); + return new SourceFile(name, path, content, languageOf(name), false); + } + + private static FileNode buildZipTree(File file, List messages) throws IOException { + FileNode root = new FileNode(file.getName(), FileNode.Type.ARCHIVE); + root.setSourcePath(""); + root.setSize(file.length()); + int count = 0; + try (ZipFile zip = new ZipFile(file)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String path = entry.getName(); + if (path == null || path.isBlank()) { + continue; + } + if (path.endsWith("/")) { + continue; + } + count++; + if (count > 6000) { + messages.add(I18n.t("apk.error.treeLimit")); + break; + } + addZipEntry(root, path, entry.getSize(), entry.isDirectory()); + } + } + sort(root); + return root; + } + + private static void addZipEntry(FileNode root, String path, long size, boolean directory) { + String[] parts = path.split("/"); + FileNode cursor = root; + StringBuilder accumulated = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + if (part.isEmpty()) { + continue; + } + if (accumulated.length() > 0) { + accumulated.append('/'); + } + accumulated.append(part); + boolean last = i == parts.length - 1; + FileNode child = cursor.findChild(part); + if (child == null) { + FileNode.Type type = last && !directory + ? typeOf(part, path) + : FileNode.Type.FOLDER; + child = cursor.add(part, type); + child.setSourcePath(accumulated.toString()); + child.setSize(last ? size : 0); + child.setBinary(last && isBinaryName(part)); + } + cursor = child; + } + } + + private static FileNode buildDirectoryTree(File directory) throws IOException { + FileNode root = new FileNode(directory.getName(), FileNode.Type.FOLDER); + root.setSourcePath(""); + root.setSize(directorySize(directory)); + int[] count = {0}; + addDirectory(root, directory, "", count); + sort(root); + return root; + } + + private static void addDirectory(FileNode parent, File dir, String prefix, int[] count) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + java.util.Arrays.sort(files, Comparator + .comparing(File::isDirectory).reversed() + .thenComparing(f -> f.getName().toLowerCase(Locale.ROOT))); + for (File file : files) { + if (count[0] >= 6000 || file.getName().startsWith(".")) { + return; + } + count[0]++; + String path = prefix.isEmpty() ? file.getName() : prefix + "/" + file.getName(); + FileNode node = parent.add(file.getName(), + file.isDirectory() ? FileNode.Type.FOLDER : typeOf(file.getName(), path)); + node.setSourcePath(path); + node.setSize(file.isFile() ? file.length() : 0); + node.setBinary(file.isFile() && isBinaryName(file.getName())); + if (file.isDirectory() && count[0] < 6000) { + addDirectory(node, file, path, count); + } + } + } + + private static void analyzeZip(File file, ApkAnalysis analysis, List messages) { + int apkFiles = 0; + int resources = 0; + int smali = 0; + try (ZipFile zip = new ZipFile(file)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.isDirectory()) { + continue; + } + apkFiles++; + String name = entry.getName().toLowerCase(Locale.ROOT); + if (name.startsWith("res/")) { + resources++; + } + if (name.endsWith(".smali")) { + smali++; + } + } + } catch (IOException ignored) { + } + analysis.setFileCount(apkFiles); + analysis.setResources(resources); + analysis.setSmaliFiles(smali); + + ApkModule module = null; + try { + module = ApkModule.loadApkFile(file); + AndroidManifestBlock manifest = module.getAndroidManifest(); + if (manifest != null) { + if (manifest.getPackageName() != null) { + analysis.setPackageName(manifest.getPackageName()); + } + if (manifest.getVersionName() != null) { + analysis.setVersionName(manifest.getVersionName()); + } + Integer versionCode = manifest.getVersionCode(); + if (versionCode != null) { + analysis.setVersionCode(versionCode); + } + Integer minSdk = manifest.getMinSdkVersion(); + if (minSdk != null) { + analysis.setMinSdk(minSdk); + } + Integer targetSdk = manifest.getTargetSdkVersion(); + if (targetSdk != null) { + analysis.setTargetSdk(targetSdk); + } + int permissions = 0; + if (manifest.getManifestElement() != null) { + permissions = manifest.getManifestElement().getElementsCount("uses-permission"); + } + analysis.setPermissions(permissions); + int components = manifest.listActivities().size(); + if (manifest.getManifestElement() != null) { + components += manifest.getManifestElement().getElementsCount("service"); + components += manifest.getManifestElement().getElementsCount("receiver"); + components += manifest.getManifestElement().getElementsCount("provider"); + } + analysis.setComponents(components); + } + } catch (Throwable error) { + messages.add(I18n.t("apk.error.manifest", shortMessage(error))); + } + + try { + if (module != null) { + DexDirectory dex = DexDirectory.fromZip(module.getZipEntryMap()); + int classes = 0; + Iterator iterator = dex.getClassIds(); + while (iterator.hasNext() && classes < 1_000_000) { + iterator.next(); + classes++; + } + analysis.setClasses(classes); + dex.close(); + } + } catch (Throwable error) { + messages.add(I18n.t("apk.error.classes", shortMessage(error))); + } finally { + if (module != null) { + try { + module.close(); + } catch (IOException ignored) { + } + } + } + } + + private static void analyzeDirectory(File directory, ApkAnalysis analysis, List messages) { + int[] stats = new int[4]; + walkStats(directory, stats); + analysis.setFileCount(stats[0]); + analysis.setSmaliFiles(stats[1]); + analysis.setResources(stats[2]); + analysis.setClasses(stats[1]); + analysis.setPackageName(guessPackage(directory)); + messages.add(I18n.t("apk.error.projectLoaded", directory.getAbsolutePath())); + } + + private static void walkStats(File dir, int[] stats) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + if (file.isDirectory()) { + walkStats(file, stats); + } else { + stats[0]++; + String name = file.getName().toLowerCase(Locale.ROOT); + if (name.endsWith(".smali")) { + stats[1]++; + } + if (name.endsWith(".xml") || name.endsWith(".arsc")) { + stats[2]++; + } + } + } + } + + private static String guessPackage(File directory) { + File smali = new File(directory, "smali"); + File[] children = smali.listFiles(); + if (children == null || children.length == 0) { + return "—"; + } + File first = children[0]; + StringBuilder name = new StringBuilder(first.getName()); + File cursor = first; + while (cursor.isDirectory()) { + File[] list = cursor.listFiles(); + if (list == null || list.length != 1) { + break; + } + cursor = list[0]; + name.append('.').append(cursor.getName()); + } + return name.toString(); + } + + private static void sort(FileNode node) { + List children = node.childNodes(); + children.sort(Comparator + .comparing((FileNode n) -> !n.isContainer()) + .thenComparing(n -> n.getName().toLowerCase(Locale.ROOT))); + node.removeAllChildren(); + for (FileNode child : children) { + node.add(child); + sort(child); + } + } + + private static FileNode.Type typeOf(String name, String path) { + String lower = name.toLowerCase(Locale.ROOT); + if (lower.equals("androidmanifest.xml")) { + return FileNode.Type.MANIFEST; + } + if (lower.endsWith(".smali")) { + return FileNode.Type.SMALI; + } + if (lower.endsWith(".xml")) { + return FileNode.Type.XML; + } + if (lower.endsWith(".dex")) { + return FileNode.Type.DEX; + } + if (lower.endsWith(".arsc")) { + return FileNode.Type.RESOURCE; + } + if (lower.endsWith(".so")) { + return FileNode.Type.LIBRARY; + } + if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") + || lower.endsWith(".webp") || lower.endsWith(".gif") + || lower.endsWith(".9.png")) { + return FileNode.Type.IMAGE; + } + if (path.startsWith("assets/")) { + return FileNode.Type.ASSETS; + } + return FileNode.Type.FILE; + } + + private static String languageOf(String name) { + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".smali")) { + return "smali"; + } + if (lower.endsWith(".xml")) { + return "xml"; + } + if (lower.endsWith(".json")) { + return "json"; + } + if (lower.endsWith(".java")) { + return "java"; + } + return "text"; + } + + private static boolean isBinaryName(String name) { + String lower = name.toLowerCase(Locale.ROOT); + return lower.endsWith(".dex") || lower.endsWith(".so") || lower.endsWith(".arsc") + || lower.endsWith(".png") || lower.endsWith(".jpg") + || lower.endsWith(".jpeg") || lower.endsWith(".webp") + || lower.endsWith(".gif") || lower.endsWith(".rsa") + || lower.endsWith(".sf") || lower.endsWith(".zip") + || lower.endsWith(".jar") || lower.endsWith(".apk"); + } + + private static boolean containsNul(byte[] bytes) { + int limit = Math.min(bytes.length, 2048); + for (int i = 0; i < limit; i++) { + if (bytes[i] == 0) { + return true; + } + } + return false; + } + + private static byte[] readLimited(InputStream input, int max) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int total = 0; + int read; + while ((read = input.read(buffer)) >= 0) { + if (total + read > max) { + output.write(buffer, 0, Math.max(0, max - total)); + break; + } + output.write(buffer, 0, read); + total += read; + } + return output.toByteArray(); + } + + private static String hexDump(byte[] bytes, int max) { + StringBuilder builder = new StringBuilder(); + int length = Math.min(bytes.length, max); + for (int offset = 0; offset < length; offset += 16) { + builder.append(String.format(Locale.ROOT, "%08X ", offset)); + for (int i = 0; i < 16; i++) { + if (offset + i < length) { + builder.append(String.format(Locale.ROOT, "%02X ", bytes[offset + i])); + } else { + builder.append(" "); + } + if (i == 7) { + builder.append(' '); + } + } + builder.append(" |"); + for (int i = 0; i < 16 && offset + i < length; i++) { + int value = bytes[offset + i] & 0xFF; + builder.append(value >= 32 && value < 127 ? (char) value : '.'); + } + builder.append("|\n"); + } + if (bytes.length > max) { + builder.append(I18n.t("apk.hexTruncated")).append('\n'); + } + return builder.toString(); + } + + private static long directorySize(File dir) { + File[] files = dir.listFiles(); + if (files == null) { + return 0; + } + long size = 0; + for (File file : files) { + size += file.isDirectory() ? directorySize(file) : file.length(); + } + return size; + } + + private static boolean isZip(File file) { + String name = file.getName().toLowerCase(Locale.ROOT); + return name.endsWith(".apk") || name.endsWith(".zip") + || name.endsWith(".jar") || name.endsWith(".aar"); + } + + private static String stripExtension(String name) { + int index = name.lastIndexOf('.'); + return index > 0 ? name.substring(0, index) : name; + } + + private static String shortMessage(Throwable error) { + String message = error.getMessage(); + if (message == null || message.isBlank()) { + return error.getClass().getSimpleName(); + } + return message.length() > 120 ? message.substring(0, 120) + "..." : message; + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/service/LogService.java b/src/main/java/com/reandroid/apkeditor/gui/service/LogService.java new file mode 100644 index 00000000..aa2645aa --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/service/LogService.java @@ -0,0 +1,124 @@ +package com.reandroid.apkeditor.gui.service; + +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.LogEntry; +import com.reandroid.apkeditor.gui.model.LogLevel; + +import javax.swing.SwingUtilities; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Bus de logs de la interfaz. Cualquier capa puede publicar mensajes sin + * conocer los componentes visuales que los muestran. + */ +public final class LogService { + public interface Listener { + void onEntry(LogEntry entry); + void onCleared(); + } + + private static final LogService INSTANCE = new LogService(); + + private final List entries = new ArrayList<>(); + private final List listeners = new CopyOnWriteArrayList<>(); + + private LogService() { + } + + public static LogService get() { + return INSTANCE; + } + + public synchronized void add(LogLevel level, String message) { + if (message == null) { + return; + } + LogEntry entry = new LogEntry(level, message); + entries.add(entry); + for (Listener listener : listeners) { + SwingUtilities.invokeLater(() -> listener.onEntry(entry)); + } + } + + public void info(String message) { + add(LogLevel.INFO, message); + } + + public void success(String message) { + add(LogLevel.SUCCESS, message); + } + + public void warning(String message) { + add(LogLevel.WARNING, message); + } + + public void error(String message) { + add(LogLevel.ERROR, message); + } + + public void command(String message) { + add(LogLevel.COMMAND, message); + } + + public void debug(String message) { + add(LogLevel.DEBUG, message); + } + + public synchronized List getEntries() { + return new ArrayList<>(entries); + } + + public synchronized void clear() { + entries.clear(); + for (Listener listener : listeners) { + SwingUtilities.invokeLater(listener::onCleared); + } + } + + public synchronized void addListener(Listener listener) { + if (listener != null) { + listeners.add(listener); + } + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + public synchronized void seedDemoIfEmpty() { + if (!entries.isEmpty()) { + return; + } + info(I18n.t("seed.workEnvironment")); + info(I18n.t("seed.loading")); + info(I18n.t("seed.verifying")); + info(I18n.t("seed.decompileStarted")); + info(I18n.t("seed.extracting")); + info(I18n.t("seed.generating")); + info(I18n.t("seed.manifest")); + info(I18n.t("seed.libraries")); + success(I18n.t("seed.decompileDone")); + info(I18n.t("seed.analysisStarted")); + info(I18n.t("seed.permissions")); + info(I18n.t("seed.components")); + info(I18n.t("seed.vulnerabilities")); + success(I18n.t("seed.analysisDone")); + } + + public synchronized void export(File file) throws IOException { + if (file == null) { + return; + } + StringBuilder builder = new StringBuilder(); + for (LogEntry entry : entries) { + builder.append(entry.toPlainText()).append(System.lineSeparator()); + } + Files.writeString(file.toPath(), builder.toString(), StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/service/OperationService.java b/src/main/java/com/reandroid/apkeditor/gui/service/OperationService.java new file mode 100644 index 00000000..8865e797 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/service/OperationService.java @@ -0,0 +1,145 @@ +package com.reandroid.apkeditor.gui.service; + +import com.reandroid.apkeditor.core.LegacyOptionsOperation; +import com.reandroid.apkeditor.core.Operation; +import com.reandroid.apkeditor.core.OperationListener; +import com.reandroid.apkeditor.core.OperationRegistry; +import com.reandroid.apkeditor.core.OperationResult; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.LogLevel; +import com.reandroid.apkeditor.gui.model.LogEntry; + +import javax.swing.SwingUtilities; +import java.io.File; +import java.util.function.Consumer; + +/** + * Ejecuta las operaciones del motor en segundo plano. La UI sólo consume + * callbacks; no conoce Decompiler, Builder, Protector, etc. + */ +public final class OperationService { + public interface Completion { + void onFinished(boolean success, Throwable error); + } + + private final OperationRegistry registry; + private final LogService logService; + + public OperationService(OperationRegistry registry, LogService logService) { + this.registry = registry; + this.logService = logService; + } + + public OperationRegistry getRegistry() { + return registry; + } + + public boolean execute(String id, File input, File output, + Consumer busy, Consumer progress, + Completion completion) { + Operation operation = registry.get(id); + if (operation == null) { + logService.error(I18n.t("op.error.notRegistered", id)); + return false; + } + if (!(operation instanceof LegacyOptionsOperation legacy)) { + logService.error(I18n.t("op.error.unsupportedAdapter", id)); + return false; + } + if (input != null) { + legacy.getOptions().inputFile = input; + } + if (output != null) { + legacy.getOptions().outputFile = output; + } + + if (busy != null) { + busy.accept(true); + } + logService.command("> apkeditor " + id + + (input == null ? "" : " -i " + input.getAbsolutePath()) + + (output == null ? "" : " -o " + output.getAbsolutePath())); + + Thread worker = new Thread(() -> { + java.util.concurrent.atomic.AtomicBoolean completed = + new java.util.concurrent.atomic.AtomicBoolean(false); + try { + operation.execute(new OperationListener() { + @Override + public void onProgress(int percent) { + if (progress != null) { + SwingUtilities.invokeLater(() -> progress.accept(percent)); + } + } + + @Override + public void onMessage(String message) { + logService.add(LogLevel.INFO, message); + } + + @Override + public void onWarning(String message) { + logService.add(LogLevel.WARNING, message); + } + + @Override + public void onError(String message, Throwable error) { + String text = message; + if ((text == null || text.isBlank()) && error != null) { + text = error.getMessage(); + } + logService.add(LogLevel.ERROR, text == null + ? I18n.t("op.error.unknown") : text); + } + + @Override + public void onCompleted(OperationResult result) { + completed.set(true); + SwingUtilities.invokeLater(() -> { + if (result.isSuccess()) { + logService.success(I18n.t("op.completedWithTime", + operation.getName(), result.getDurationMillis())); + } else { + logService.error(I18n.t("op.failedLog", operation.getName())); + } + if (progress != null) { + progress.accept(result.isSuccess() ? 100 : 0); + } + if (busy != null) { + busy.accept(false); + } + if (completion != null) { + completion.onFinished(result.isSuccess(), result.getError()); + } + }); + } + }); + } catch (Exception error) { + if (completed.get()) { + return; + } + SwingUtilities.invokeLater(() -> { + logService.error(I18n.t("op.aborted", shortMessage(error))); + if (progress != null) { + progress.accept(0); + } + if (busy != null) { + busy.accept(false); + } + if (completion != null) { + completion.onFinished(false, error); + } + }); + } + }, "apkeditor-" + id); + worker.setDaemon(true); + worker.start(); + return true; + } + + private static String shortMessage(Throwable error) { + String message = error.getMessage(); + return message == null || message.isBlank() + ? error.getClass().getSimpleName() : message; + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/service/SampleProject.java b/src/main/java/com/reandroid/apkeditor/gui/service/SampleProject.java new file mode 100644 index 00000000..82f55bbb --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/service/SampleProject.java @@ -0,0 +1,356 @@ +package com.reandroid.apkeditor.gui.service; + +import com.reandroid.apkeditor.gui.model.FileNode; + +/** + * Estructura de ejemplo usada cuando no hay un APK cargado. Todos los + * documentos son reales para el editor y el explorador, no imágenes. + */ +public final class SampleProject { + private SampleProject() {} + + public static final String MAIN_ACTIVITY_PATH = + "app-debug.apk/smali/com/example/myapp/MainActivity.smali"; + public static final String MANIFEST_PATH = "app-debug.apk/AndroidManifest.xml"; + public static final String STRINGS_PATH = "app-debug.apk/res/values/strings.xml"; + + public static FileNode buildTree() { + FileNode root = new FileNode("app-debug.apk", FileNode.Type.ARCHIVE); + root.setSourcePath("app-debug.apk"); + root.setSize(4_812_544L); + + FileNode metaInf = root.add("META-INF", FileNode.Type.FOLDER); + metaInf.add("MANIFEST.MF", FileNode.Type.FILE) + .setContent("Manifest-Version: 1.0\nCreated-By: APKEditor\n"); + metaInf.add("CERT.RSA", FileNode.Type.FILE).setBinary(true); + + FileNode res = root.add("res", FileNode.Type.FOLDER); + FileNode layout = res.add("layout", FileNode.Type.FOLDER); + layout.add("activity_main.xml", FileNode.Type.XML) + .setContent(activityLayout()); + FileNode values = res.add("values", FileNode.Type.FOLDER); + values.add("strings.xml", FileNode.Type.XML).setContent(stringsXml()); + values.add("colors.xml", FileNode.Type.XML).setContent(colorsXml()); + FileNode drawable = res.add("drawable", FileNode.Type.FOLDER); + drawable.add("ic_launcher.xml", FileNode.Type.XML) + .setContent("\n"); + res.add("mipmap", FileNode.Type.FOLDER) + .add("ic_launcher.png", FileNode.Type.IMAGE) + .setBinary(true); + + FileNode smali = root.add("smali", FileNode.Type.FOLDER); + FileNode com = smali.add("com", FileNode.Type.FOLDER); + FileNode example = com.add("example", FileNode.Type.FOLDER); + FileNode myapp = example.add("myapp", FileNode.Type.FOLDER); + myapp.add("MainActivity.smali", FileNode.Type.SMALI).setContent(mainActivity()); + myapp.add("Utils.smali", FileNode.Type.SMALI).setContent(utils()); + myapp.add("BuildConfig.smali", FileNode.Type.SMALI).setContent(buildConfig()); + myapp.setSourcePath("app-debug.apk/smali/com/example/myapp"); + + FileNode smali2 = root.add("smali_classes2", FileNode.Type.FOLDER); + FileNode com2 = smali2.add("com", FileNode.Type.FOLDER); + FileNode example2 = com2.add("example", FileNode.Type.FOLDER); + FileNode myapp2 = example2.add("myapp", FileNode.Type.FOLDER); + myapp2.add("NetworkClient.smali", FileNode.Type.SMALI).setContent(networkClient()); + + FileNode assets = root.add("assets", FileNode.Type.FOLDER); + assets.add("config.json", FileNode.Type.FILE) + .setContent("{\n \"endpoint\": \"https://api.example.com\",\n \"debug\": false\n}\n"); + + FileNode lib = root.add("lib", FileNode.Type.FOLDER); + lib.add("armeabi-v7a", FileNode.Type.FOLDER) + .add("libnative.so", FileNode.Type.LIBRARY).setBinary(true); + lib.add("x86", FileNode.Type.FOLDER) + .add("libnative.so", FileNode.Type.LIBRARY).setBinary(true); + + root.add("AndroidManifest.xml", FileNode.Type.MANIFEST).setContent(manifestXml()); + root.add("resources.arsc", FileNode.Type.RESOURCE).setBinary(true); + + root.setSourcePath(null); + applyPaths(root, "app-debug.apk"); + return root; + } + + private static void applyPaths(FileNode node, String prefix) { + for (FileNode child : node.childNodes()) { + String path = prefix + "/" + child.getName(); + child.setSourcePath(path); + if (child.isContainer()) { + applyPaths(child, path); + } + } + } + + public static FileNode find(FileNode root, String path) { + if (root == null || path == null) { + return null; + } + return findRecursive(root, path); + } + + private static FileNode findRecursive(FileNode node, String path) { + if (path.equals(node.getSourcePath())) { + return node; + } + for (FileNode child : node.childNodes()) { + FileNode result = findRecursive(child, path); + if (result != null) { + return result; + } + } + return null; + } + + public static String mainActivity() { + return """ + .class public Lcom/example/myapp/MainActivity; + .super Landroidx/appcompat/app/AppCompatActivity; + .source "MainActivity.java" + + # direct methods + .method public constructor ()V + .locals 0 + + .line 12 + invoke-direct {p0}, Landroidx/appcompat/app/AppCompatActivity;->()V + + return-void + .end method + + # virtual methods + .method protected onCreate(Landroid/os/Bundle;)V + .locals 3 + + .line 18 + invoke-super {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->onCreate(Landroid/os/Bundle;)V + + .line 19 + const v0, 0x7f0b001c + + .line 20 + invoke-virtual {p0, v0}, Lcom/example/myapp/MainActivity;->setContentView(I)V + + .line 22 + new-instance v0, Landroid/content/Intent; + + .line 23 + const-class v1, Lcom/example/myapp/Utils; + + .line 24 + invoke-direct {v0, p0, v1}, Landroid/content/Intent;->(Landroid/content/Context;Ljava/lang/Class;)V + + .line 25 + invoke-virtual {p0, v0}, Lcom/example/myapp/MainActivity;->startActivity(Landroid/content/Intent;)V + + .line 27 + const-string v2, "APKEditor :: workspace ready" + + .line 28 + invoke-static {v2}, Lcom/example/myapp/Utils;->log(Ljava/lang/String;)V + + return-void + .end method + + .method private loadPreferences()V + .locals 4 + + const-string v0, "preferences" + + .line 34 + const/4 v1, 0x0 + + invoke-virtual {p0, v0, v1}, Lcom/example/myapp/MainActivity;->getSharedPreferences(Ljava/lang/String;I)Landroid/content/SharedPreferences; + + move-result-object v0 + + const-string v1, "first_run" + + const/4 v2, 0x1 + + invoke-interface {v0, v1, v2}, Landroid/content/SharedPreferences;->getBoolean(Ljava/lang/String;Z)Z + + move-result v0 + + if-eqz v0, :cond_ready + + invoke-direct {p0}, Lcom/example/myapp/MainActivity;->showWelcome()V + + :cond_ready + return-void + .end method + """; + } + + private static String utils() { + return """ + .class public final Lcom/example/myapp/Utils; + .super Ljava/lang/Object; + .source "Utils.java" + + .field private static final TAG:Ljava/lang/String; = "MyApplication" + + .method public static log(Ljava/lang/String;)V + .locals 1 + + const-string v0, "MyApplication" + + invoke-static {v0, p0}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I + + return-void + .end method + + .method public static md5(Ljava/lang/String;)Ljava/lang/String; + .locals 3 + + const-string v0, "MD5" + + invoke-static {v0}, Ljava/security/MessageDigest;->getInstance(Ljava/lang/String;)Ljava/security/MessageDigest; + + move-result-object v0 + + sget-object v1, Ljava/nio/charset/StandardCharsets;->UTF_8:Ljava/nio/charset/Charset; + + invoke-virtual {p0, v1}, Ljava/lang/String;->getBytes(Ljava/nio/charset/Charset;)[B + + move-result-object v1 + + invoke-virtual {v0, v1}, Ljava/security/MessageDigest;->digest([B)[B + + move-result-object v0 + + const/16 v1, 0x10 + + invoke-static {v0, v1}, Landroid/util/Base64;->encodeToString([BI)Ljava/lang/String; + + move-result-object v0 + + return-object v0 + .end method + """; + } + + private static String buildConfig() { + return """ + .class public final Lcom/example/myapp/BuildConfig; + .super Ljava/lang/Object; + .source "BuildConfig.java" + + .field public static final APPLICATION_ID:Ljava/lang/String; = "com.example.myapp" + + .field public static final BUILD_TYPE:Ljava/lang/String; = "debug" + + .field public static final DEBUG:Z = true + + .field public static final VERSION_CODE:I = 0x1 + + .field public static final VERSION_NAME:Ljava/lang/String; = "1.0.0" + + .method public constructor ()V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void + .end method + """; + } + + private static String networkClient() { + return """ + .class public Lcom/example/myapp/NetworkClient; + .super Ljava/lang/Object; + .source "NetworkClient.java" + + .field private final baseUrl:Ljava/lang/String; + + .method public constructor (Ljava/lang/String;)V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + iput-object p1, p0, Lcom/example/myapp/NetworkClient;->baseUrl:Ljava/lang/String; + + return-void + .end method + """; + } + + private static String activityLayout() { + return """ + + + + + + + """; + } + + private static String stringsXml() { + return """ + + + MyApplication + Welcome to APKEditor + Sistema listo + + """; + } + + private static String colorsXml() { + return """ + + + #020B18 + #00BFFF + #E6F4FF + + """; + } + + private static String manifestXml() { + return """ + + + + + + + + + + + + + + + + + + + + + + + """; + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/theme/Colors.java b/src/main/java/com/reandroid/apkeditor/gui/theme/Colors.java new file mode 100644 index 00000000..0fcb2c89 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/theme/Colors.java @@ -0,0 +1,53 @@ +package com.reandroid.apkeditor.gui.theme; + +import java.awt.Color; + +/** + * Paleta visual compartida por todos los componentes de la interfaz. + * Los valores son mutables para permitir el cambio de tema en caliente. + */ +public final class Colors { + private Colors() {} + + public static Color BACKGROUND = new Color(0x020B18); + public static Color BACKGROUND_SECONDARY = new Color(0x041426); + public static Color SURFACE = new Color(0x06182A); + public static Color SURFACE_2 = new Color(0x071D32); + public static Color PANEL = new Color(0x06182A); + public static Color PANEL_SECONDARY = new Color(0x071D32); + public static Color PANEL_HOVER = new Color(0x0B2945); + public static Color BORDER = new Color(0x123653); + public static Color BORDER_ACTIVE = new Color(0x087FBC); + + public static Color PRIMARY = new Color(0x00BFFF); + public static Color PRIMARY_BRIGHT = new Color(0x00D5FF); + public static Color CYAN = new Color(0x00E5FF); + public static Color SECONDARY = new Color(0xA855F7); + public static Color PURPLE = new Color(0xA855F7); + public static Color MAGENTA = new Color(0xD946EF); + public static Color SUCCESS = new Color(0x00E6A0); + public static Color GREEN = new Color(0x00E6A0); + public static Color WARNING = new Color(0xFFB547); + public static Color ERROR = new Color(0xFF4668); + + public static Color TEXT = new Color(0xE6F4FF); + public static Color TEXT_SECONDARY = new Color(0x9CB4CC); + public static Color TEXT_MUTED = new Color(0x64829F); + + public static Color NAV_ACTIVE_BG = new Color(0x07304C); + public static Color NAV_ACTIVE_TEXT = new Color(0x00D5FF); + public static Color GLOW = new Color(0x00BFFF); + + public static Color alpha(Color color, int alpha) { + return new Color(color.getRed(), color.getGreen(), color.getBlue(), alpha); + } + + public static Color mix(Color a, Color b, float ratio) { + float r = Math.min(1f, Math.max(0f, ratio)); + return new Color( + Math.round(a.getRed() * (1f - r) + b.getRed() * r), + Math.round(a.getGreen() * (1f - r) + b.getGreen() * r), + Math.round(a.getBlue() * (1f - r) + b.getBlue() * r), + Math.round(a.getAlpha() * (1f - r) + b.getAlpha() * r)); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/theme/Fonts.java b/src/main/java/com/reandroid/apkeditor/gui/theme/Fonts.java new file mode 100644 index 00000000..6c131f48 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/theme/Fonts.java @@ -0,0 +1,46 @@ +package com.reandroid.apkeditor.gui.theme; + +import java.awt.Font; +import java.awt.GraphicsEnvironment; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Tipografías de la aplicación. Usa Inter / JetBrains Mono cuando están + * instaladas y cae en las fuentes lógicas del sistema cuando no lo están. + */ +public final class Fonts { + private Fonts() {} + + private static final String SANS = resolve("Inter", "Segoe UI", "SansSerif"); + private static final String MONO_FAMILY = resolveMonospace(); + + public static final Font TITLE = new Font(SANS, Font.BOLD, 22); + public static final Font SECTION = new Font(SANS, Font.BOLD, 14); + public static final Font SUBTITLE = new Font(SANS, Font.PLAIN, 12); + public static final Font BODY = new Font(SANS, Font.PLAIN, 13); + public static final Font BODY_MEDIUM = new Font(SANS, Font.BOLD, 12); + public static final Font SMALL = new Font(SANS, Font.PLAIN, 11); + public static final Font TINY = new Font(SANS, Font.PLAIN, 10); + public static final Font MONO = new Font(MONO_FAMILY, Font.PLAIN, 13); + public static final Font MONO_SMALL = new Font(MONO_FAMILY, Font.PLAIN, 11); + public static final Font CODE = new Font(MONO_FAMILY, Font.PLAIN, 13); + + private static String resolve(String... families) { + Set available = new HashSet<>(Arrays.asList( + GraphicsEnvironment.getLocalGraphicsEnvironment() + .getAvailableFontFamilyNames())); + for (String family : families) { + if (available.contains(family)) { + return family; + } + } + return "SansSerif"; + } + + private static String resolveMonospace() { + return resolve("JetBrains Mono", "Cascadia Code", "Fira Code", + "Source Code Pro", "Consolas", "Monospaced"); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/theme/IconFactory.java b/src/main/java/com/reandroid/apkeditor/gui/theme/IconFactory.java new file mode 100644 index 00000000..97edfba3 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/theme/IconFactory.java @@ -0,0 +1,359 @@ +package com.reandroid.apkeditor.gui.theme; + +import javax.swing.Icon; +import java.awt.*; +import java.awt.geom.*; + +/** + * Iconos vectoriales dibujados con Java2D. Evita depender de imágenes + * estáticas y mantiene el mismo lenguaje visual en todos los temas. + */ +public final class IconFactory { + private IconFactory() {} + + public static Icon icon(String name, int size) { + return icon(name, size, Colors.TEXT_SECONDARY); + } + + public static Icon icon(String name, int size, Color color) { + return new VectorIcon(name, size, color); + } + + private static final class VectorIcon implements Icon { + private final String name; + private final int size; + private final Color color; + + private VectorIcon(String name, int size, Color color) { + this.name = name; + this.size = size; + this.color = color; + } + + @Override + public int getIconWidth() { + return size; + } + + @Override + public int getIconHeight() { + return size; + } + + @Override + public void paintIcon(Component c, Graphics g, int x, int y) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, + RenderingHints.VALUE_STROKE_PURE); + g2.translate(x, y); + g2.scale(size / 24.0, size / 24.0); + paint(g2, name, color); + } finally { + g2.dispose(); + } + } + } + + private static void paint(Graphics2D g, String name, Color color) { + g.setColor(color); + g.setStroke(new BasicStroke(1.7f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + switch (name == null ? "" : name) { + case "house" -> { + Path2D p = new Path2D.Double(); + p.moveTo(3, 11); + p.lineTo(12, 3.5); + p.lineTo(21, 11); + g.draw(p); + g.draw(new RoundRectangle2D.Double(6, 10.5, 12, 10, 2, 2)); + g.draw(new RoundRectangle2D.Double(10.2, 14.5, 3.6, 6, 1, 1)); + } + case "file-code" -> { + fileOutline(g); + Path2D p = new Path2D.Double(); + p.moveTo(9.2, 13.2); + p.lineTo(7.6, 15.3); + p.lineTo(9.2, 17.4); + p.moveTo(14.8, 13.2); + p.lineTo(16.4, 15.3); + p.lineTo(14.8, 17.4); + g.draw(p); + } + case "binary" -> { + g.draw(new RoundRectangle2D.Double(4, 4, 16, 16, 3, 3)); + g.setFont(new Font("Monospaced", Font.BOLD, 8)); + g.drawString("10", 7, 11); + g.drawString("01", 11, 18); + } + case "scan-search", "search" -> { + g.draw(new Ellipse2D.Double(4.5, 4.5, 12, 12)); + g.draw(new Line2D.Double(15.5, 15.5, 20, 20)); + if ("scan-search".equals(name)) { + g.draw(new Line2D.Double(7.5, 10.5, 13.5, 10.5)); + } + } + case "package" -> { + Path2D p = new Path2D.Double(); + p.moveTo(12, 3); + p.lineTo(20.5, 7.2); + p.lineTo(20.5, 16.8); + p.lineTo(12, 21); + p.lineTo(3.5, 16.8); + p.lineTo(3.5, 7.2); + p.closePath(); + g.draw(p); + g.draw(new Line2D.Double(3.8, 7.4, 12, 11.6)); + g.draw(new Line2D.Double(20.2, 7.4, 12, 11.6)); + g.draw(new Line2D.Double(12, 11.6, 12, 20.6)); + } + case "git-merge" -> { + g.draw(new Ellipse2D.Double(5, 3.5, 5, 5)); + g.draw(new Ellipse2D.Double(14, 15.5, 5, 5)); + g.draw(new Ellipse2D.Double(5, 15.5, 5, 5)); + Path2D p = new Path2D.Double(); + p.moveTo(7.5, 8.5); + p.lineTo(7.5, 12); + p.quadTo(7.5, 18, 14, 18); + g.draw(p); + } + case "workflow" -> { + g.draw(new RoundRectangle2D.Double(3, 4, 7, 5, 1.5, 1.5)); + g.draw(new RoundRectangle2D.Double(14, 4, 7, 5, 1.5, 1.5)); + g.draw(new RoundRectangle2D.Double(8.5, 15, 7, 5, 1.5, 1.5)); + g.draw(new Line2D.Double(10, 6.5, 14, 6.5)); + g.draw(new Line2D.Double(6.5, 9, 6.5, 12)); + g.draw(new Line2D.Double(17.5, 9, 17.5, 12)); + Path2D p = new Path2D.Double(); + p.moveTo(6.5, 12); + p.lineTo(12, 12); + p.lineTo(12, 15); + g.draw(p); + } + case "shield" -> { + Path2D p = new Path2D.Double(); + p.moveTo(12, 3); + p.lineTo(20, 6); + p.lineTo(20, 12.5); + p.quadTo(20, 18, 12, 21); + p.quadTo(4, 18, 4, 12.5); + p.lineTo(4, 6); + p.closePath(); + g.draw(p); + Path2D check = new Path2D.Double(); + check.moveTo(8.2, 12.2); + check.lineTo(11, 15); + check.lineTo(16, 9.5); + g.draw(check); + } + case "info" -> { + g.draw(new Ellipse2D.Double(4, 4, 16, 16)); + g.draw(new Line2D.Double(12, 10.5, 12, 17)); + g.fill(new Ellipse2D.Double(11.1, 7, 1.8, 1.8)); + } + case "settings" -> { + g.draw(new Ellipse2D.Double(7, 7, 10, 10)); + g.draw(new Ellipse2D.Double(10.2, 10.2, 3.6, 3.6)); + for (int i = 0; i < 8; i++) { + double a = Math.PI * 2 * i / 8.0; + double x1 = 12 + Math.cos(a) * 7.1; + double y1 = 12 + Math.sin(a) * 7.1; + double x2 = 12 + Math.cos(a) * 9.2; + double y2 = 12 + Math.sin(a) * 9.2; + g.draw(new Line2D.Double(x1, y1, x2, y2)); + } + } + case "close" -> { + g.draw(new Line2D.Double(6, 6, 18, 18)); + g.draw(new Line2D.Double(18, 6, 6, 18)); + } + case "minimize" -> g.draw(new Line2D.Double(6, 12, 18, 12)); + case "maximize" -> g.draw(new RoundRectangle2D.Double(6, 6, 12, 12, 1.5, 1.5)); + case "restore" -> { + g.draw(new RoundRectangle2D.Double(5, 8, 11, 11, 1.5, 1.5)); + g.draw(new RoundRectangle2D.Double(8, 5, 11, 11, 1.5, 1.5)); + } + case "trash" -> { + g.draw(new Line2D.Double(4.5, 7, 19.5, 7)); + g.draw(new Line2D.Double(9, 4, 15, 4)); + Path2D p = new Path2D.Double(); + p.moveTo(6, 7); + p.lineTo(7.2, 20); + p.lineTo(16.8, 20); + p.lineTo(18, 7); + g.draw(p); + g.draw(new Line2D.Double(10, 10.5, 10.4, 17)); + g.draw(new Line2D.Double(14, 10.5, 13.6, 17)); + } + case "download" -> { + g.draw(new Line2D.Double(12, 4, 12, 15)); + Path2D p = new Path2D.Double(); + p.moveTo(7.5, 10.5); + p.lineTo(12, 15); + p.lineTo(16.5, 10.5); + g.draw(p); + Path2D tray = new Path2D.Double(); + tray.moveTo(4, 15); + tray.lineTo(4, 20); + tray.lineTo(20, 20); + tray.lineTo(20, 15); + g.draw(tray); + } + case "copy" -> { + g.draw(new RoundRectangle2D.Double(8, 8, 11, 11, 2, 2)); + Path2D p = new Path2D.Double(); + p.moveTo(16, 8); + p.lineTo(16, 5); + p.lineTo(5, 5); + p.lineTo(5, 16); + p.lineTo(8, 16); + g.draw(p); + } + case "folder" -> { + Path2D p = new Path2D.Double(); + p.moveTo(3.5, 6.5); + p.lineTo(9.5, 6.5); + p.lineTo(11.2, 8.5); + p.lineTo(20.5, 8.5); + p.lineTo(20.5, 18.5); + p.lineTo(3.5, 18.5); + p.closePath(); + g.draw(p); + } + case "folder-open" -> { + Path2D p = new Path2D.Double(); + p.moveTo(3.5, 7); + p.lineTo(3.5, 18.5); + p.lineTo(6, 11); + p.lineTo(21, 11); + p.lineTo(18.5, 18.5); + p.lineTo(6, 18.5); + g.draw(p); + g.draw(new Line2D.Double(3.5, 7, 9, 7)); + } + case "file" -> fileOutline(g); + case "archive" -> { + g.draw(new RoundRectangle2D.Double(4.5, 5, 15, 15, 2, 2)); + g.draw(new Line2D.Double(9, 5, 9, 9)); + g.draw(new Line2D.Double(12, 5, 12, 9)); + g.draw(new Line2D.Double(15, 5, 15, 9)); + g.draw(new RoundRectangle2D.Double(10.5, 11, 3, 4, 1, 1)); + } + case "android" -> { + Path2D head = new Path2D.Double(); + head.moveTo(5, 10); + head.quadTo(12, 6.5, 19, 10); + head.lineTo(19, 15); + head.quadTo(19, 18, 16, 18); + head.lineTo(8, 18); + head.quadTo(5, 18, 5, 15); + head.closePath(); + g.draw(head); + g.draw(new Line2D.Double(8, 6.5, 6.5, 4)); + g.draw(new Line2D.Double(16, 6.5, 17.5, 4)); + g.fill(new Ellipse2D.Double(8.8, 11.2, 1.5, 1.5)); + g.fill(new Ellipse2D.Double(13.7, 11.2, 1.5, 1.5)); + } + case "layers" -> { + Path2D a = new Path2D.Double(); + a.moveTo(12, 3.5); + a.lineTo(21, 8); + a.lineTo(12, 12.5); + a.lineTo(3, 8); + a.closePath(); + g.draw(a); + g.draw(new Line2D.Double(3, 12, 12, 16.5)); + g.draw(new Line2D.Double(21, 12, 12, 16.5)); + g.draw(new Line2D.Double(3, 15.7, 12, 20.2)); + g.draw(new Line2D.Double(21, 15.7, 12, 20.2)); + } + case "blocks" -> { + g.draw(new RoundRectangle2D.Double(4, 4, 6.5, 6.5, 1.2, 1.2)); + g.draw(new RoundRectangle2D.Double(13.5, 4, 6.5, 6.5, 1.2, 1.2)); + g.draw(new RoundRectangle2D.Double(4, 13.5, 6.5, 6.5, 1.2, 1.2)); + g.draw(new RoundRectangle2D.Double(13.5, 13.5, 6.5, 6.5, 1.2, 1.2)); + } + case "chevron-right" -> { + g.draw(new Line2D.Double(9.5, 6, 15.5, 12)); + g.draw(new Line2D.Double(15.5, 12, 9.5, 18)); + } + case "chevron-down" -> { + g.draw(new Line2D.Double(6, 9.5, 12, 15.5)); + g.draw(new Line2D.Double(12, 15.5, 18, 9.5)); + } + case "check" -> { + Path2D p = new Path2D.Double(); + p.moveTo(5, 12.5); + p.lineTo(10, 17.5); + p.lineTo(19, 7); + g.draw(p); + } + case "clock" -> { + g.draw(new Ellipse2D.Double(4, 4, 16, 16)); + g.draw(new Line2D.Double(12, 7.5, 12, 12.3)); + g.draw(new Line2D.Double(12, 12.3, 15.5, 14)); + } + case "play" -> { + Path2D p = new Path2D.Double(); + p.moveTo(7, 4.5); + p.lineTo(19.5, 12); + p.lineTo(7, 19.5); + p.closePath(); + g.fill(p); + } + case "terminal" -> { + g.draw(new RoundRectangle2D.Double(3.5, 5, 17, 14, 2, 2)); + Path2D p = new Path2D.Double(); + p.moveTo(7, 9.5); + p.lineTo(10, 12); + p.lineTo(7, 14.5); + g.draw(p); + g.draw(new Line2D.Double(12.5, 15, 16.5, 15)); + } + case "code" -> { + Path2D p = new Path2D.Double(); + p.moveTo(9, 7); + p.lineTo(4.5, 12); + p.lineTo(9, 17); + p.moveTo(15, 7); + p.lineTo(19.5, 12); + p.lineTo(15, 17); + g.draw(p); + } + case "menu" -> { + g.draw(new Line2D.Double(4, 7, 20, 7)); + g.draw(new Line2D.Double(4, 12, 20, 12)); + g.draw(new Line2D.Double(4, 17, 20, 17)); + } + case "refresh" -> { + g.draw(new Arc2D.Double(4, 4, 16, 16, 35, 280, Arc2D.OPEN)); + Path2D p = new Path2D.Double(); + p.moveTo(18.5, 4.5); + p.lineTo(19.5, 9); + p.lineTo(15, 8); + g.draw(p); + } + default -> { + g.draw(new Ellipse2D.Double(4, 4, 16, 16)); + g.fill(new Ellipse2D.Double(10.5, 10.5, 3, 3)); + } + } + } + + private static void fileOutline(Graphics2D g) { + Path2D p = new Path2D.Double(); + p.moveTo(6, 3.5); + p.lineTo(14, 3.5); + p.lineTo(19, 8.5); + p.lineTo(19, 20.5); + p.lineTo(6, 20.5); + p.closePath(); + g.draw(p); + Path2D fold = new Path2D.Double(); + fold.moveTo(14, 3.5); + fold.lineTo(14, 8.5); + fold.lineTo(19, 8.5); + g.draw(fold); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/theme/Theme.java b/src/main/java/com/reandroid/apkeditor/gui/theme/Theme.java new file mode 100644 index 00000000..c4c43c69 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/theme/Theme.java @@ -0,0 +1,215 @@ +package com.reandroid.apkeditor.gui.theme; + +import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.border.EmptyBorder; +import java.awt.*; + +/** + * Sistema de temas y utilidades de estilos. No contiene lógica de negocio. + */ +public final class Theme { + private Theme() { + } + + public static final String[] NAMES = {"Futuristic", "Cyberpunk", "Dark", "Midnight"}; + private static String current = "Futuristic"; + + public static void install() { + UIManager.put("Panel.background", Colors.BACKGROUND); + UIManager.put("Label.foreground", Colors.TEXT); + UIManager.put("TextField.background", Colors.SURFACE_2); + UIManager.put("TextField.foreground", Colors.TEXT); + UIManager.put("TextField.caretForeground", Colors.PRIMARY); + UIManager.put("TextPane.background", Colors.SURFACE); + UIManager.put("TextPane.foreground", Colors.TEXT); + UIManager.put("TextArea.background", Colors.SURFACE); + UIManager.put("TextArea.foreground", Colors.TEXT); + UIManager.put("Button.background", Colors.SURFACE_2); + UIManager.put("Button.foreground", Colors.TEXT); + UIManager.put("Button.font", Fonts.BODY); + UIManager.put("ComboBox.background", Colors.SURFACE_2); + UIManager.put("ComboBox.foreground", Colors.TEXT); + UIManager.put("ComboBox.selectionBackground", Colors.PANEL_HOVER); + UIManager.put("ComboBox.selectionForeground", Colors.PRIMARY_BRIGHT); + UIManager.put("ScrollPane.border", BorderFactory.createEmptyBorder()); + UIManager.put("ScrollBar.width", 10); + UIManager.put("ScrollBar.thumb", Colors.BORDER); + UIManager.put("ScrollBar.track", Colors.BACKGROUND_SECONDARY); + UIManager.put("Tree.background", Colors.PANEL); + UIManager.put("Tree.foreground", Colors.TEXT); + UIManager.put("Tree.selectionBackground", Colors.PANEL_HOVER); + UIManager.put("Tree.selectionForeground", Colors.PRIMARY_BRIGHT); + UIManager.put("ToolTip.background", Colors.PANEL_SECONDARY); + UIManager.put("ToolTip.foreground", Colors.TEXT); + UIManager.put("ToolTip.border", BorderFactory.createLineBorder(Colors.BORDER)); + UIManager.put("SplitPane.background", Colors.BACKGROUND); + UIManager.put("SplitPane.dividerSize", 14); + UIManager.put("SplitPane.border", BorderFactory.createEmptyBorder()); + UIManager.put("SplitPaneDivider.border", BorderFactory.createEmptyBorder()); + UIManager.put("SplitPaneDivider.draggingColor", Colors.BORDER_ACTIVE); + apply(current); + } + + public static String current() { + return current; + } + + public static void apply(String name) { + if (name == null || name.isBlank()) { + name = "Futuristic"; + } + current = name; + switch (name) { + case "Cyberpunk" -> cyberpunk(); + case "Dark" -> dark(); + case "Midnight" -> midnight(); + default -> futuristic(); + } + } + + private static void futuristic() { + Colors.BACKGROUND = new Color(0x020B18); + Colors.BACKGROUND_SECONDARY = new Color(0x041426); + Colors.SURFACE = new Color(0x06182A); + Colors.SURFACE_2 = new Color(0x071D32); + Colors.PANEL = new Color(0x06182A); + Colors.PANEL_SECONDARY = new Color(0x071D32); + Colors.PANEL_HOVER = new Color(0x0B2945); + Colors.BORDER = new Color(0x123653); + Colors.BORDER_ACTIVE = new Color(0x087FBC); + Colors.PRIMARY = new Color(0x00BFFF); + Colors.PRIMARY_BRIGHT = new Color(0x00D5FF); + Colors.CYAN = new Color(0x00E5FF); + Colors.SECONDARY = new Color(0xA855F7); + Colors.PURPLE = new Color(0xA855F7); + Colors.MAGENTA = new Color(0xD946EF); + Colors.SUCCESS = new Color(0x00E6A0); + Colors.GREEN = new Color(0x00E6A0); + Colors.WARNING = new Color(0xFFB547); + Colors.ERROR = new Color(0xFF4668); + Colors.TEXT = new Color(0xE6F4FF); + Colors.TEXT_SECONDARY = new Color(0x9CB4CC); + Colors.TEXT_MUTED = new Color(0x64829F); + Colors.NAV_ACTIVE_BG = new Color(0x07304C); + Colors.NAV_ACTIVE_TEXT = new Color(0x00D5FF); + Colors.GLOW = new Color(0x00BFFF); + } + + private static void cyberpunk() { + Colors.BACKGROUND = new Color(0x090012); + Colors.BACKGROUND_SECONDARY = new Color(0x12001E); + Colors.SURFACE = new Color(0x1A0526); + Colors.SURFACE_2 = new Color(0x240833); + Colors.PANEL = new Color(0x1A0526); + Colors.PANEL_SECONDARY = new Color(0x240833); + Colors.PANEL_HOVER = new Color(0x351047); + Colors.BORDER = new Color(0x4A1B6B); + Colors.BORDER_ACTIVE = new Color(0xB52BFF); + Colors.PRIMARY = new Color(0xFF2FB3); + Colors.PRIMARY_BRIGHT = new Color(0xFF6FD8); + Colors.CYAN = new Color(0x00E5FF); + Colors.SECONDARY = new Color(0xA855F7); + Colors.PURPLE = new Color(0xA855F7); + Colors.MAGENTA = new Color(0xFF2FB3); + Colors.SUCCESS = new Color(0x00FF9C); + Colors.GREEN = new Color(0x00FF9C); + Colors.WARNING = new Color(0xFFC857); + Colors.ERROR = new Color(0xFF3355); + Colors.TEXT = new Color(0xFFE8FA); + Colors.TEXT_SECONDARY = new Color(0xC9A0DC); + Colors.TEXT_MUTED = new Color(0x8A6A9E); + Colors.NAV_ACTIVE_BG = new Color(0x3B0D58); + Colors.NAV_ACTIVE_TEXT = new Color(0xFF6FD8); + Colors.GLOW = new Color(0xFF2FB3); + } + + private static void dark() { + Colors.BACKGROUND = new Color(0x0B0F14); + Colors.BACKGROUND_SECONDARY = new Color(0x0E131A); + Colors.SURFACE = new Color(0x121820); + Colors.SURFACE_2 = new Color(0x171F29); + Colors.PANEL = new Color(0x121820); + Colors.PANEL_SECONDARY = new Color(0x171F29); + Colors.PANEL_HOVER = new Color(0x1E2A37); + Colors.BORDER = new Color(0x2A3A4A); + Colors.BORDER_ACTIVE = new Color(0x2B7DA8); + Colors.PRIMARY = new Color(0x38BDF8); + Colors.PRIMARY_BRIGHT = new Color(0x7DD3FC); + Colors.CYAN = new Color(0x22D3EE); + Colors.SECONDARY = new Color(0xA78BFA); + Colors.PURPLE = new Color(0xA78BFA); + Colors.MAGENTA = new Color(0xE879F9); + Colors.SUCCESS = new Color(0x34D399); + Colors.GREEN = new Color(0x34D399); + Colors.WARNING = new Color(0xFBBF24); + Colors.ERROR = new Color(0xF87171); + Colors.TEXT = new Color(0xE5EDF5); + Colors.TEXT_SECONDARY = new Color(0x9FB0C0); + Colors.TEXT_MUTED = new Color(0x6B7C8F); + Colors.NAV_ACTIVE_BG = new Color(0x1B3648); + Colors.NAV_ACTIVE_TEXT = new Color(0x7DD3FC); + Colors.GLOW = new Color(0x38BDF8); + } + + private static void midnight() { + Colors.BACKGROUND = new Color(0x030616); + Colors.BACKGROUND_SECONDARY = new Color(0x050B22); + Colors.SURFACE = new Color(0x08102B); + Colors.SURFACE_2 = new Color(0x0B1638); + Colors.PANEL = new Color(0x08102B); + Colors.PANEL_SECONDARY = new Color(0x0B1638); + Colors.PANEL_HOVER = new Color(0x132155); + Colors.BORDER = new Color(0x1E3270); + Colors.BORDER_ACTIVE = new Color(0x4C7DFF); + Colors.PRIMARY = new Color(0x4C7DFF); + Colors.PRIMARY_BRIGHT = new Color(0x6C9BFF); + Colors.CYAN = new Color(0x29D3FF); + Colors.SECONDARY = new Color(0x8B5CF6); + Colors.PURPLE = new Color(0x8B5CF6); + Colors.MAGENTA = new Color(0xE879F9); + Colors.SUCCESS = new Color(0x2CE0B0); + Colors.GREEN = new Color(0x2CE0B0); + Colors.WARNING = new Color(0xFFC24B); + Colors.ERROR = new Color(0xFF5D7A); + Colors.TEXT = new Color(0xE7EEFF); + Colors.TEXT_SECONDARY = new Color(0xA0B0D0); + Colors.TEXT_MUTED = new Color(0x65779E); + Colors.NAV_ACTIVE_BG = new Color(0x172A63); + Colors.NAV_ACTIVE_TEXT = new Color(0x8FB0FF); + Colors.GLOW = new Color(0x4C7DFF); + } + + public static JPanel panel(LayoutManager layout) { + JPanel panel = new JPanel(layout); + panel.setBackground(Colors.BACKGROUND); + return panel; + } + + public static Border empty(int size) { + return new EmptyBorder(size, size, size, size); + } + + public static Border empty(int top, int left, int bottom, int right) { + return new EmptyBorder(top, left, bottom, right); + } + + public static void card(JComponent component) { + component.setOpaque(true); + component.setBackground(Colors.SURFACE); + component.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(Colors.BORDER), + new EmptyBorder(16, 16, 16, 16))); + } + + public static void styleField(JTextField field) { + field.setBackground(Colors.SURFACE_2); + field.setForeground(Colors.TEXT); + field.setCaretColor(Colors.PRIMARY); + field.setSelectionColor(Colors.PANEL_HOVER); + field.setSelectedTextColor(Colors.PRIMARY_BRIGHT); + field.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(Colors.BORDER), + new EmptyBorder(6, 8, 6, 8))); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/tree/FileExplorer.java b/src/main/java/com/reandroid/apkeditor/gui/tree/FileExplorer.java new file mode 100644 index 00000000..da872c91 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/tree/FileExplorer.java @@ -0,0 +1,343 @@ +package com.reandroid.apkeditor.gui.tree; + +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.components.SearchField; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.FileNode; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.event.TreeExpansionEvent; +import javax.swing.event.TreeWillExpandListener; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import java.awt.*; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Explorador de archivos funcional: expande, contrae, filtra y abre + * documentos reales en el editor. + */ +public final class FileExplorer extends PanelCard { + private final SearchField search = new SearchField(I18n.t("explorer.search")); + private final JTree tree = new JTree(); + private final Consumer onOpen; + private final Set expandedPaths = new HashSet<>(); + private FileNode originalRoot; + private String selectedPath; + private boolean restoring; + + public FileExplorer(FileNode root, Consumer onOpen) { + super(I18n.t("explorer.title"), "folder"); + this.onOpen = onOpen; + getTitleLabel().setFont(Fonts.SECTION); + setSubtitle(I18n.t("explorer.subtitle")); + + tree.setOpaque(false); + tree.setRowHeight(26); + tree.setFont(Fonts.BODY); + tree.setBackground(new Color(0, 0, 0, 0)); + tree.setForeground(Colors.TEXT); + tree.setCellRenderer(new ExplorerRenderer()); + tree.setShowsRootHandles(true); + tree.setToggleClickCount(1); + tree.setBorder(BorderFactory.createEmptyBorder(4, 0, 4, 0)); + tree.getSelectionModel().setSelectionMode( + javax.swing.tree.TreeSelectionModel.SINGLE_TREE_SELECTION); + ToolTipManager.sharedInstance().registerComponent(tree); + tree.setToolTipText(""); + + tree.addTreeSelectionListener(e -> { + if (restoring) { + return; + } + TreePath path = tree.getSelectionPath(); + if (path == null) { + return; + } + Object value = path.getLastPathComponent(); + if (value instanceof FileNode node) { + selectedPath = node.getSourcePath(); + if (!node.isContainer() && onOpen != null) { + onOpen.accept(node); + } + } + }); + tree.addTreeWillExpandListener(new TreeWillExpandListener() { + @Override + public void treeWillExpand(TreeExpansionEvent event) { + Object value = event.getPath().getLastPathComponent(); + if (value instanceof FileNode node && node.getSourcePath() != null) { + expandedPaths.add(node.getSourcePath()); + } + } + + @Override + public void treeWillCollapse(TreeExpansionEvent event) { + Object value = event.getPath().getLastPathComponent(); + if (value instanceof FileNode node && node.getSourcePath() != null) { + expandedPaths.remove(node.getSourcePath()); + } + } + }); + + JScrollPane scroll = new JScrollPane(tree); + scroll.setOpaque(false); + scroll.getViewport().setOpaque(false); + scroll.setBorder(BorderFactory.createEmptyBorder()); + scroll.getVerticalScrollBar().setUnitIncrement(18); + + search.addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + applyFilter(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + applyFilter(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + applyFilter(); + } + }); + + JPanel body = getBody(); + body.setLayout(new BorderLayout(0, 8)); + body.add(search, BorderLayout.NORTH); + body.add(scroll, BorderLayout.CENTER); + + setRoot(root, root != null && "app-debug.apk".equals(root.getName())); + } + + public JTree getTree() { + return tree; + } + + public void setRoot(FileNode root, boolean expandSample) { + this.originalRoot = root; + this.tree.setModel(new DefaultTreeModel(root)); + this.expandedPaths.clear(); + this.selectedPath = null; + restoring = true; + try { + if (expandSample && root != null) { + expandSamplePath(); + } else if (root != null) { + tree.expandPath(new TreePath(root.getPath())); + } + } finally { + restoring = false; + } + applyFilter(); + } + + public void focusSearch() { + search.getField().requestFocusInWindow(); + search.getField().selectAll(); + } + + public void selectPath(String path) { + if (path == null || originalRoot == null) { + return; + } + FileNode node = find(originalRoot, path); + if (node == null) { + return; + } + restoring = true; + try { + TreePath treePath = new TreePath(node.getPath()); + tree.expandPath(treePath.getParentPath()); + tree.setSelectionPath(treePath); + tree.scrollPathToVisible(treePath); + } finally { + restoring = false; + } + selectedPath = path; + } + + public void openSelected() { + TreePath path = tree.getSelectionPath(); + if (path != null && path.getLastPathComponent() instanceof FileNode node + && !node.isContainer() && onOpen != null) { + onOpen.accept(node); + } + } + + private void expandSamplePath() { + FileNode root = originalRoot; + tree.expandPath(new TreePath(root.getPath())); + expandByName(root, new TreePath(root.getPath()), "smali"); + FileNode smali = root.findChild("smali"); + if (smali != null) { + FileNode com = smali.findChild("com"); + FileNode example = com == null ? null : com.findChild("example"); + FileNode myapp = example == null ? null : example.findChild("myapp"); + if (myapp != null) { + tree.expandPath(new TreePath(myapp.getPath())); + FileNode main = myapp.findChild("MainActivity.smali"); + if (main != null) { + TreePath mainPath = new TreePath(main.getPath()); + tree.setSelectionPath(mainPath); + tree.scrollPathToVisible(mainPath); + selectedPath = main.getSourcePath(); + } + } + } + } + + private void expandByName(FileNode node, TreePath path, String name) { + for (FileNode child : node.childNodes()) { + TreePath childPath = path.pathByAddingChild(child); + if (child.getName().equals(name) || child.getSourcePath() != null + && child.getSourcePath().endsWith("/" + name)) { + tree.expandPath(childPath); + return; + } + } + } + + private void applyFilter() { + if (originalRoot == null) { + return; + } + String query = search.getText().trim().toLowerCase(Locale.ROOT); + FileNode root = query.isEmpty() ? originalRoot : filter(originalRoot, query); + if (root == null) { + root = originalRoot.shallowCopy(); + } + restoring = true; + try { + tree.setModel(new DefaultTreeModel(root)); + restoreExpanded(root, new TreePath(root.getPath())); + if (selectedPath != null) { + FileNode selected = find(root, selectedPath); + if (selected != null) { + TreePath path = new TreePath(selected.getPath()); + tree.expandPath(path.getParentPath()); + tree.setSelectionPath(path); + } + } + } finally { + restoring = false; + } + } + + private void restoreExpanded(FileNode root, TreePath path) { + if (expandedPaths.contains(root.getSourcePath())) { + tree.expandPath(path); + } + for (FileNode child : root.childNodes()) { + restoreExpanded(child, path.pathByAddingChild(child)); + } + } + + private FileNode filter(FileNode node, String query) { + boolean selfMatch = node.getName().toLowerCase(Locale.ROOT).contains(query); + if (!node.isContainer()) { + return selfMatch ? node.shallowCopy() : null; + } + FileNode copy = null; + for (FileNode child : node.childNodes()) { + FileNode filtered = filter(child, query); + if (filtered != null) { + if (copy == null) { + copy = node.shallowCopy(); + } + copy.add(filtered); + } + } + if (copy != null) { + return copy; + } + return selfMatch ? node.shallowCopy() : null; + } + + private FileNode find(FileNode node, String path) { + if (path.equals(node.getSourcePath())) { + return node; + } + for (FileNode child : node.childNodes()) { + FileNode result = find(child, path); + if (result != null) { + return result; + } + } + return null; + } + + private final class ExplorerRenderer extends DefaultTreeCellRenderer { + private ExplorerRenderer() { + setOpaque(false); + setBackgroundNonSelectionColor(new Color(0, 0, 0, 0)); + setBackgroundSelectionColor(Colors.PANEL_HOVER); + setTextNonSelectionColor(Colors.TEXT_SECONDARY); + setTextSelectionColor(Colors.PRIMARY_BRIGHT); + setBorderSelectionColor(null); + setFont(Fonts.BODY); + setBorder(BorderFactory.createEmptyBorder(3, 4, 3, 6)); + } + + @Override + public Component getTreeCellRendererComponent(JTree tree, Object value, + boolean selected, boolean expanded, + boolean leaf, int row, boolean hasFocus) { + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, false); + setFont(Fonts.BODY); + setBorder(BorderFactory.createEmptyBorder(3, 4, 3, 6)); + if (value instanceof FileNode node) { + setText(node.getName()); + setIcon(iconFor(node, expanded)); + setToolTipText(node.getSourcePath()); + if (selected) { + setForeground(Colors.PRIMARY_BRIGHT); + } else if (node.isContainer()) { + setForeground(Colors.TEXT); + } else { + setForeground(Colors.TEXT_SECONDARY); + } + } + return this; + } + + private Icon iconFor(FileNode node, boolean expanded) { + Color color = switch (node.getType()) { + case SMALI -> Colors.PRIMARY; + case XML -> Colors.WARNING; + case DEX -> Colors.MAGENTA; + case RESOURCE -> Colors.PURPLE; + case IMAGE -> Colors.SUCCESS; + case LIBRARY -> Colors.WARNING; + case MANIFEST -> Colors.CYAN; + case ASSETS -> Colors.TEXT_SECONDARY; + case ARCHIVE -> Colors.PRIMARY_BRIGHT; + case FOLDER -> expanded ? Colors.PRIMARY : Colors.TEXT_SECONDARY; + default -> Colors.TEXT_MUTED; + }; + String icon = switch (node.getType()) { + case ARCHIVE -> "archive"; + case FOLDER -> expanded ? "folder-open" : "folder"; + case SMALI, XML -> "file-code"; + case MANIFEST -> "file"; + case IMAGE -> "layers"; + case LIBRARY -> "blocks"; + case DEX -> "binary"; + case RESOURCE -> "database"; + default -> "file"; + }; + return IconFactory.icon(icon, 15, color); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/view/DashboardView.java b/src/main/java/com/reandroid/apkeditor/gui/view/DashboardView.java new file mode 100644 index 00000000..f3420d04 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/view/DashboardView.java @@ -0,0 +1,246 @@ +package com.reandroid.apkeditor.gui.view; + +import com.reandroid.apkeditor.gui.components.ActionTile; +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.components.StatusDot; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.ApkAnalysis; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.navigation.Route; +import com.reandroid.apkeditor.gui.panel.ApplicationInfoPanel; +import com.reandroid.apkeditor.gui.panel.StatisticsPanel; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import java.awt.*; +import java.beans.PropertyChangeListener; +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Centro de control: estado del proyecto, estadísticas y accesos directos. + */ +public final class DashboardView extends JPanel { + private final AppState state; + private final Consumer navigate; + private final ApplicationInfoPanel information = new ApplicationInfoPanel(); + private final StatisticsPanel statistics = new StatisticsPanel(); + private final Map projectValues = new LinkedHashMap<>(); + private final StatusDot inputDot = new StatusDot(Colors.SUCCESS); + private final JLabel inputStatus = new JLabel(I18n.t("dashboard.status.ready")); + private final PropertyChangeListener analysisListener; + private final PropertyChangeListener inputListener; + + public DashboardView(AppState state, Consumer navigate) { + super(new BorderLayout(0, 16)); + this.state = state; + this.navigate = navigate; + setBackground(Colors.BACKGROUND); + setBorder(BorderFactory.createEmptyBorder(18, 18, 18, 18)); + + add(createHeader(), BorderLayout.NORTH); + add(createContent(), BorderLayout.CENTER); + + analysisListener = event -> SwingUtilities.invokeLater( + () -> applyAnalysis((ApkAnalysis) event.getNewValue())); + inputListener = event -> SwingUtilities.invokeLater(this::applyInput); + state.addPropertyChangeListener(AppState.ANALYSIS_CHANGED, analysisListener); + state.addPropertyChangeListener(AppState.INPUT_CHANGED, inputListener); + + applyAnalysis(state.getAnalysis()); + applyInput(); + } + + private JComponent createHeader() { + JLabel title = new JLabel(I18n.t("dashboard.title")); + title.setFont(Fonts.TITLE); + title.setForeground(Colors.TEXT); + + JLabel subtitle = new JLabel(I18n.t("dashboard.subtitle")); + subtitle.setFont(Fonts.SMALL); + subtitle.setForeground(Colors.TEXT_MUTED); + + JPanel titles = new JPanel(new BorderLayout(0, 3)); + titles.setOpaque(false); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + + NeonButton open = new NeonButton(I18n.t("dashboard.openWorkspace"), + IconFactory.icon("code", 14, Colors.BACKGROUND)); + open.setVariant(NeonButton.Variant.PRIMARY); + open.addActionListener(e -> navigate.accept(Route.DECOMPILE)); + + JPanel header = new JPanel(new BorderLayout(12, 0)); + header.setOpaque(false); + header.add(titles, BorderLayout.WEST); + header.add(open, BorderLayout.EAST); + return header; + } + + private JComponent createContent() { + JPanel content = new JPanel(new GridBagLayout()); + content.setOpaque(false); + + JPanel top = new JPanel(new GridBagLayout()); + top.setOpaque(false); + + GridBagConstraints c = new GridBagConstraints(); + c.gridy = 0; + c.fill = GridBagConstraints.BOTH; + c.weighty = 1.0; + + c.gridx = 0; + c.weightx = 0.34; + c.insets = new Insets(0, 0, 0, 14); + top.add(information, c); + + c.gridx = 1; + c.weightx = 0.32; + top.add(statistics, c); + + c.gridx = 2; + c.weightx = 0.34; + c.insets = new Insets(0, 0, 0, 0); + top.add(createProjectCard(), c); + + GridBagConstraints outer = new GridBagConstraints(); + outer.gridx = 0; + outer.gridy = 0; + outer.weightx = 1.0; + outer.weighty = 0.45; + outer.fill = GridBagConstraints.BOTH; + content.add(top, outer); + + outer.gridy = 1; + outer.weighty = 0.55; + outer.insets = new Insets(14, 0, 0, 0); + content.add(createActions(), outer); + return content; + } + + private JComponent createProjectCard() { + PanelCard card = new PanelCard(I18n.t("dashboard.projectTitle"), "package"); + card.setSubtitle(I18n.t("dashboard.currentSession")); + + JPanel body = card.getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + + JPanel status = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + status.setOpaque(false); + inputStatus.setFont(Fonts.SMALL); + inputStatus.setForeground(Colors.SUCCESS); + status.add(inputDot); + status.add(inputStatus); + status.setAlignmentX(LEFT_ALIGNMENT); + body.add(status); + body.add(Box.createVerticalStrut(8)); + + addProjectRow(body, I18n.t("dashboard.field.apk")); + addProjectRow(body, I18n.t("dashboard.field.path")); + addProjectRow(body, I18n.t("dashboard.field.size")); + addProjectRow(body, I18n.t("dashboard.field.output")); + body.add(Box.createVerticalGlue()); + + JLabel version = new JLabel(I18n.t("dashboard.version")); + version.setFont(Fonts.TINY); + version.setForeground(Colors.TEXT_MUTED); + version.setAlignmentX(LEFT_ALIGNMENT); + body.add(version); + return card; + } + + private void addProjectRow(JPanel body, String key) { + JPanel row = new JPanel(new BorderLayout(8, 0)); + row.setOpaque(false); + row.setBorder(BorderFactory.createEmptyBorder(2, 0, 2, 0)); + row.setAlignmentX(LEFT_ALIGNMENT); + + JLabel label = new JLabel(key); + label.setFont(Fonts.SMALL); + label.setForeground(Colors.TEXT_MUTED); + + JLabel value = new JLabel("—"); + value.setFont(Fonts.SMALL); + value.setForeground(Colors.TEXT); + value.setHorizontalAlignment(SwingConstants.RIGHT); + + row.add(label, BorderLayout.WEST); + row.add(value, BorderLayout.CENTER); + projectValues.put(key, value); + body.add(row); + } + + private JComponent createActions() { + PanelCard card = new PanelCard(I18n.t("dashboard.actionsTitle"), "workflow"); + card.setSubtitle(I18n.t("dashboard.actionsSubtitle")); + + JPanel grid = new JPanel(new GridLayout(2, 4, 10, 10)); + grid.setOpaque(false); + + addAction(grid, "file-code", I18n.t("dashboard.action.decompile"), + I18n.t("dashboard.action.decompileDesc"), Route.DECOMPILE); + addAction(grid, "scan-search", I18n.t("dashboard.action.analyze"), + I18n.t("dashboard.action.analyzeDesc"), Route.ANALYZE); + addAction(grid, "binary", I18n.t("dashboard.action.smali"), + I18n.t("dashboard.action.smaliDesc"), Route.SMALI_VIEWER); + addAction(grid, "package", I18n.t("dashboard.action.build"), + I18n.t("dashboard.action.buildDesc"), Route.BUILD); + addAction(grid, "git-merge", I18n.t("dashboard.action.merge"), + I18n.t("dashboard.action.mergeDesc"), Route.MERGE); + addAction(grid, "workflow", I18n.t("dashboard.action.refactor"), + I18n.t("dashboard.action.refactorDesc"), Route.REFACTOR); + addAction(grid, "shield", I18n.t("dashboard.action.protect"), + I18n.t("dashboard.action.protectDesc"), Route.PROTECT); + addAction(grid, "info", I18n.t("dashboard.action.info"), + I18n.t("dashboard.action.infoDesc"), Route.INFORMATION); + + card.getBody().setLayout(new BorderLayout()); + card.getBody().add(grid, BorderLayout.CENTER); + return card; + } + + private void addAction(JPanel parent, String icon, String title, + String description, Route route) { + ActionTile tile = new ActionTile(icon, title, description); + tile.addActionListener(e -> navigate.accept(route)); + parent.add(tile); + } + + private void applyInput() { + File input = state.getCurrentInput(); + projectValues.get(I18n.t("dashboard.field.apk")).setText(input == null + ? I18n.t("dashboard.demoApk") : input.getName()); + projectValues.get(I18n.t("dashboard.field.path")).setText(input == null + ? I18n.t("dashboard.demoWorkspace") : input.getParent()); + projectValues.get(I18n.t("dashboard.field.path")).setToolTipText( + input == null ? null : input.getAbsolutePath()); + projectValues.get(I18n.t("dashboard.field.size")).setText(input == null + ? "4.6 MB" : state.getAnalysis().getSizeLabel()); + File output = state.getCurrentOutput(); + projectValues.get(I18n.t("dashboard.field.output")).setText( + output == null ? "—" : output.getName()); + projectValues.get(I18n.t("dashboard.field.output")).setToolTipText( + output == null ? null : output.getAbsolutePath()); + inputStatus.setText(input == null + ? I18n.t("dashboard.status.demo") : I18n.t("dashboard.status.loaded")); + inputDot.setColor(input == null ? Colors.WARNING : Colors.SUCCESS); + inputStatus.setForeground(input == null ? Colors.WARNING : Colors.SUCCESS); + } + + private void applyAnalysis(ApkAnalysis analysis) { + information.setAnalysis(analysis); + statistics.setAnalysis(analysis); + } + + @Override + public void removeNotify() { + state.removePropertyChangeListener(analysisListener); + state.removePropertyChangeListener(inputListener); + super.removeNotify(); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/view/DecompileView.java b/src/main/java/com/reandroid/apkeditor/gui/view/DecompileView.java new file mode 100644 index 00000000..dc550938 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/view/DecompileView.java @@ -0,0 +1,650 @@ +package com.reandroid.apkeditor.gui.view; + +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.editor.EditorTabs; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.log.LogPanel; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.model.ApkAnalysis; +import com.reandroid.apkeditor.gui.model.FileNode; +import com.reandroid.apkeditor.gui.model.SourceFile; +import com.reandroid.apkeditor.gui.panel.BottomDock; +import com.reandroid.apkeditor.gui.service.ApkService; +import com.reandroid.apkeditor.gui.service.LogService; +import com.reandroid.apkeditor.gui.service.OperationService; +import com.reandroid.apkeditor.gui.service.SampleProject; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; +import com.reandroid.apkeditor.gui.tree.FileExplorer; + +import javax.swing.*; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.*; +import java.beans.PropertyChangeListener; +import java.io.File; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +/** + * Espacio de trabajo principal de descompilación: explorador, editor, + * logs, progreso, información y estadísticas. + */ +public final class DecompileView extends JPanel { + private static final String TAB_STRUCTURE = "structure"; + private static final String TAB_CODE = "code"; + private static final String TAB_MANIFEST = "manifest"; + private static final String TAB_RESOURCES = "resources"; + private static final String TAB_ANALYSIS = "analysis"; + + private final AppState state; + private final OperationService operations; + private final LogService logService; + private final Consumer apkLoader; + + private final FileExplorer explorer; + private final EditorTabs editor; + private final LogPanel logPanel; + private final BottomDock bottom = new BottomDock(); + private final JLabel subtitle = new JLabel(); + private final JPanel tabsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + private final List tabs = new java.util.ArrayList<>(); + private WorkspaceTab activeTab; + private Timer activityTimer; + + private final PropertyChangeListener treeListener; + private final PropertyChangeListener analysisListener; + private final PropertyChangeListener inputListener; + + public DecompileView(AppState state, OperationService operations, + LogService logService, Consumer apkLoader) { + super(new BorderLayout(0, 14)); + this.state = state; + this.operations = operations; + this.logService = logService; + this.apkLoader = apkLoader; + + setBackground(Colors.BACKGROUND); + setBorder(BorderFactory.createEmptyBorder(16, 18, 16, 18)); + + FileNode initialTree = state.getCurrentTree(); + if (initialTree == null) { + initialTree = SampleProject.buildTree(); + } + explorer = new FileExplorer(initialTree, this::openNode); + SourceFile initialFile = new SourceFile( + "MainActivity.smali", + SampleProject.MAIN_ACTIVITY_PATH, + SampleProject.mainActivity(), "smali", false); + editor = new EditorTabs(state, initialFile); + logPanel = new LogPanel(logService); + bottom.setAnalysis(state.getAnalysis()); + + add(createHeader(), BorderLayout.NORTH); + add(createWorkspace(), BorderLayout.CENTER); + + treeListener = event -> { + FileNode root = (FileNode) event.getNewValue(); + if (root == null) { + root = SampleProject.buildTree(); + } + FileNode finalRoot = root; + SwingUtilities.invokeLater(() -> explorer.setRoot(finalRoot, + finalRoot.getName().equals("app-debug.apk"))); + }; + analysisListener = event -> SwingUtilities.invokeLater( + () -> bottom.setAnalysis((ApkAnalysis) event.getNewValue())); + inputListener = event -> SwingUtilities.invokeLater(this::updateSubtitle); + + state.addPropertyChangeListener(AppState.TREE_CHANGED, treeListener); + state.addPropertyChangeListener(AppState.ANALYSIS_CHANGED, analysisListener); + state.addPropertyChangeListener(AppState.INPUT_CHANGED, inputListener); + updateSubtitle(); + setActiveTab(TAB_STRUCTURE); + } + + private JComponent createHeader() { + JPanel container = new JPanel(new BorderLayout(0, 10)); + container.setOpaque(false); + + JLabel title = new JLabel(I18n.t("decompile.title")); + title.setFont(Fonts.TITLE); + title.setForeground(Colors.TEXT); + subtitle.setFont(Fonts.SMALL); + subtitle.setForeground(Colors.TEXT_MUTED); + + JPanel titles = new JPanel(new BorderLayout(0, 2)); + titles.setOpaque(false); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + + JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 7, 0)); + actions.setOpaque(false); + + NeonButton search = headerButton("search", I18n.t("decompile.searchTooltip")); + search.addActionListener(e -> explorer.focusSearch()); + NeonButton close = headerButton("close", I18n.t("decompile.resetTooltip")); + close.addActionListener(e -> resetWorkspace()); + + NeonButton run = new NeonButton(I18n.t("decompile.run"), + IconFactory.icon("play", 13, Colors.BACKGROUND)); + run.setVariant(NeonButton.Variant.PRIMARY); + run.setFont(Fonts.BODY_MEDIUM); + run.setToolTipText(I18n.t("decompile.runTooltip")); + run.addActionListener(e -> runDecompile()); + + actions.add(search); + actions.add(close); + actions.add(run); + + JPanel header = new JPanel(new BorderLayout(12, 0)); + header.setOpaque(false); + header.add(titles, BorderLayout.WEST); + header.add(actions, BorderLayout.EAST); + + tabsPanel.setOpaque(false); + String[][] tabDefinitions = { + {TAB_STRUCTURE, "tab.structure"}, + {TAB_CODE, "tab.code"}, + {TAB_MANIFEST, "tab.manifest"}, + {TAB_RESOURCES, "tab.resources"}, + {TAB_ANALYSIS, "tab.analysis"} + }; + for (String[] definition : tabDefinitions) { + WorkspaceTab tab = new WorkspaceTab(definition[0], I18n.t(definition[1])); + tab.addActionListener(e -> onTab(definition[0])); + tabs.add(tab); + tabsPanel.add(tab); + } + + container.add(header, BorderLayout.NORTH); + container.add(tabsPanel, BorderLayout.SOUTH); + return container; + } + + private NeonButton headerButton(String icon, String tooltip) { + NeonButton button = new NeonButton(IconFactory.icon(icon, 16, Colors.TEXT_SECONDARY)); + button.setVariant(NeonButton.Variant.GHOST); + button.setCompact(true); + button.setToolTipText(tooltip); + return button; + } + + private JComponent createWorkspace() { + JPanel workspace = new JPanel(new BorderLayout(0, 14)); + workspace.setOpaque(false); + + explorer.setMinimumSize(new Dimension(240, 200)); + editor.setMinimumSize(new Dimension(360, 200)); + logPanel.setMinimumSize(new Dimension(260, 200)); + + JSplitPane right = splitPane(editor, logPanel, 0.58, 360, 260); + JSplitPane columns = splitPane(explorer, right, 0.228, 240, 620); + + bottom.setPreferredSize(new Dimension(0, 175)); + bottom.setMinimumSize(new Dimension(0, 175)); + + workspace.add(columns, BorderLayout.CENTER); + workspace.add(bottom, BorderLayout.SOUTH); + return workspace; + } + + private JSplitPane splitPane(Component first, Component second, + double ratio, int minFirst, int minSecond) { + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, first, second) { + private int lastWidth = -1; + + @Override + public void doLayout() { + int width = getWidth(); + int available = width - getDividerSize(); + if (width > 0 && available > 0) { + int current = getDividerLocation(); + int target; + if (lastWidth <= 0) { + target = (int) Math.round(available * ratio); + } else if (lastWidth != width) { + double currentRatio = (double) current + / Math.max(1, lastWidth - getDividerSize()); + target = (int) Math.round(available * currentRatio); + } else { + target = current; + } + target = Math.max(minFirst, Math.min(available - minSecond, target)); + if (target != current) { + setDividerLocation(target); + } + lastWidth = width; + } + super.doLayout(); + } + }; + split.setResizeWeight(ratio); + split.setDividerSize(14); + split.setBorder(BorderFactory.createEmptyBorder()); + split.setOpaque(false); + split.setContinuousLayout(true); + split.setOneTouchExpandable(false); + split.setBackground(Colors.BACKGROUND); + split.setDividerLocation((int) Math.round(1000 * ratio)); + return split; + } + + private void onTab(String id) { + setActiveTab(id); + switch (id) { + case TAB_STRUCTURE -> { + explorer.getTree().requestFocusInWindow(); + if (state.getSelectedFilePath() != null) { + explorer.selectPath(state.getSelectedFilePath()); + } + } + case TAB_CODE -> { + if (editor.getActiveEditor() == null) { + openSampleFile(); + } else { + editor.getActiveEditor().getTextPane().requestFocusInWindow(); + } + } + case TAB_MANIFEST -> openNamed("AndroidManifest.xml"); + case TAB_RESOURCES -> { + if (!openNamed("strings.xml")) { + if (!openNamed("resources.arsc")) { + logService.info(I18n.t("decompile.resourcesNotFound")); + } + } + } + case TAB_ANALYSIS -> openAnalysisReport(); + default -> { + } + } + } + + private void setActiveTab(String id) { + for (WorkspaceTab tab : tabs) { + tab.setActive(tab.getTabId().equals(id)); + if (tab.getTabId().equals(id)) { + activeTab = tab; + } + } + tabsPanel.repaint(); + } + + public void showEditor() { + setActiveTab(TAB_CODE); + onTab(TAB_CODE); + } + + public void showStructure() { + setActiveTab(TAB_STRUCTURE); + onTab(TAB_STRUCTURE); + } + + public void showAnalysis() { + setActiveTab(TAB_ANALYSIS); + onTab(TAB_ANALYSIS); + } + + public EditorTabs getEditorTabs() { + return editor; + } + + public FileExplorer getFileExplorer() { + return explorer; + } + + private void openSampleFile() { + editor.open(new SourceFile("MainActivity.smali", + SampleProject.MAIN_ACTIVITY_PATH, SampleProject.mainActivity(), + "smali", false)); + } + + private boolean openNamed(String fileName) { + FileNode root = state.getCurrentTree(); + if (root == null) { + root = SampleProject.buildTree(); + } + FileNode node = findByName(root, fileName); + if (node == null && "AndroidManifest.xml".equals(fileName)) { + node = findByName(root, "AndroidManifest.xml"); + } + if (node != null) { + explorer.selectPath(node.getSourcePath()); + openNode(node); + return true; + } + return false; + } + + private FileNode findByName(FileNode node, String name) { + if (name.equalsIgnoreCase(node.getName())) { + return node; + } + for (FileNode child : node.childNodes()) { + FileNode result = findByName(child, name); + if (result != null) { + return result; + } + } + return null; + } + + private void openNode(FileNode node) { + if (node == null || node.isContainer()) { + return; + } + setActiveTab(TAB_CODE); + if (node.getContent() != null) { + editor.open(new SourceFile(node.getName(), node.getSourcePath(), + node.getContent(), languageOf(node), node.isBinary())); + return; + } + File source = state.getCurrentInput(); + if (source == null || node.getSourcePath() == null) { + String message = "; " + node.getSourcePath() + "\n" + + I18n.t("decompile.binaryPlaceholder") + "\n"; + editor.open(new SourceFile(node.getName(), node.getSourcePath(), + message, "hex", node.isBinary())); + return; + } + new SwingWorker() { + @Override + protected SourceFile doInBackground() throws Exception { + return ApkService.read(source, node.getSourcePath()); + } + + @Override + protected void done() { + try { + SourceFile file = get(); + if (file == null) { + logService.warning(I18n.t("decompile.openFailed", + node.getSourcePath())); + return; + } + editor.open(file); + explorer.selectPath(node.getSourcePath()); + } catch (Exception error) { + logService.error(I18n.t("decompile.readError", + node.getName(), error.getMessage())); + } + } + }.execute(); + } + + private void openAnalysisReport() { + ApkAnalysis analysis = state.getAnalysis(); + String content = """ + %s + ========================================= + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + %s: %s + + %s + ----------------------------------------- + %s: %s + %s: APKEditor core + %s: %s + %s: %s + """.formatted( + I18n.t("report.title"), + I18n.t("report.application"), analysis.getAppName(), + I18n.t("report.package"), analysis.getPackageName(), + I18n.t("report.version"), I18n.t("report.versionFormat", + analysis.getVersionName(), analysis.getVersionCode()), + I18n.t("report.minSdk"), analysis.getMinSdk(), + I18n.t("report.targetSdk"), analysis.getTargetSdk(), + I18n.t("report.apkSize"), analysis.getSizeLabel(), + I18n.t("report.files"), analysis.getFileCount(), + I18n.t("report.smaliFiles"), analysis.getSmaliFiles(), + I18n.t("report.classes"), analysis.getClasses(), + I18n.t("report.resources"), analysis.getResources(), + I18n.t("report.permissions"), analysis.getPermissions(), + I18n.t("report.components"), analysis.getComponents(), + I18n.t("report.engineStatus"), + I18n.t("report.environment"), I18n.t("report.ready"), + I18n.t("report.engine"), + I18n.t("report.interface"), I18n.t("report.interfaceValue"), + I18n.t("report.editor"), I18n.t("report.editorValue")); + String path = "apkeditor://analysis-report/" + + Integer.toHexString((analysis.getPackageName() + analysis.getVersionName() + + analysis.getFileCount() + analysis.getClasses()).hashCode()); + editor.open(new SourceFile("analysis-report.txt", path, content, "text", false)); + } + + public void runDecode() { + runDecompile(); + } + + private void runDecompile() { + if (state.isBusy()) { + logService.warning(I18n.t("decompile.busy")); + return; + } + File input = state.getCurrentInput(); + if (input == null || !input.exists()) { + input = chooseApk(); + if (input == null) { + return; + } + state.setCurrentInput(input); + if (apkLoader != null) { + apkLoader.accept(input); + } + } + File output = uniqueOutput(input); + + bottom.getProgressPanel().resetForRun(); + bottom.getProgressPanel().setDescription(I18n.t("decompile.progress")); + logService.info(I18n.t("decompile.started", input.getName())); + + int[] simulated = {4}; + if (activityTimer != null) { + activityTimer.stop(); + } + activityTimer = new Timer(420, e -> { + if (!state.isBusy()) { + activityTimer.stop(); + return; + } + simulated[0] = Math.min(90, simulated[0] + 1); + updateProgress(simulated[0]); + }); + activityTimer.start(); + + boolean started = operations.execute("decode", input, output, + busy -> state.setBusy(busy), + this::updateProgress, + (success, error) -> { + if (activityTimer != null) { + activityTimer.stop(); + } + bottom.getProgressPanel().finish(success); + if (success) { + state.setCurrentOutput(output); + bottom.getProgressPanel().setDescription( + I18n.t("decompile.outputReady", output.getAbsolutePath())); + logService.success(I18n.t("decompile.success", + output.getAbsolutePath())); + } else { + bottom.getProgressPanel().setDescription(I18n.t("decompile.failed")); + } + }); + if (!started) { + if (activityTimer != null) { + activityTimer.stop(); + } + bottom.getProgressPanel().finish(false); + } + } + + private void updateProgress(int percent) { + bottom.getProgressPanel().setProgress(percent); + if (percent < 18) { + setSteps(0); + } else if (percent < 62) { + setSteps(1); + } else if (percent < 88) { + setSteps(2); + } else { + setSteps(3); + } + } + + private void setSteps(int active) { + for (int i = 0; i < 4; i++) { + if (i < active) { + bottom.getProgressPanel().setStepStatus(i, + com.reandroid.apkeditor.gui.panel.AnalysisProgressPanel.StepStatus.COMPLETED); + } else if (i == active) { + bottom.getProgressPanel().setStepStatus(i, + com.reandroid.apkeditor.gui.panel.AnalysisProgressPanel.StepStatus.ACTIVE); + } else { + bottom.getProgressPanel().setStepStatus(i, + com.reandroid.apkeditor.gui.panel.AnalysisProgressPanel.StepStatus.PENDING); + } + } + } + + private File chooseApk() { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle(I18n.t("apk.dialogTitle")); + chooser.setFileFilter(new FileNameExtensionFilter( + I18n.t("apk.filter"), "apk", "zip", "aab")); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + return chooser.getSelectedFile(); + } + return null; + } + + private File uniqueOutput(File input) { + File parent = input.getParentFile(); + if (parent == null) { + parent = new File("."); + } + String name = input.getName(); + int dot = name.lastIndexOf('.'); + if (dot > 0) { + name = name.substring(0, dot); + } + File output = new File(parent, name + "_decompile"); + int index = 1; + while (output.exists()) { + output = new File(parent, name + "_decompile_" + index); + index++; + } + return output; + } + + private String languageOf(FileNode node) { + String name = node.getName().toLowerCase(Locale.ROOT); + if (name.endsWith(".smali")) { + return "smali"; + } + if (name.endsWith(".xml")) { + return "xml"; + } + if (name.endsWith(".json")) { + return "json"; + } + if (node.isBinary()) { + return "hex"; + } + return "text"; + } + + private void resetWorkspace() { + if (state.isBusy()) { + logService.warning(I18n.t("decompile.wait")); + return; + } + state.setCurrentInput(null); + state.setCurrentOutput(null); + state.setAnalysis(ApkAnalysis.demo()); + state.setCurrentTree(SampleProject.buildTree()); + logService.info(I18n.t("decompile.resetDone")); + } + + private void updateSubtitle() { + File input = state.getCurrentInput(); + if (input == null) { + subtitle.setText(I18n.t("decompile.demoSubtitle")); + subtitle.setToolTipText(null); + } else { + subtitle.setText(input.getName() + " · " + input.getAbsolutePath()); + subtitle.setToolTipText(input.getAbsolutePath()); + } + } + + @Override + public void removeNotify() { + if (activityTimer != null) { + activityTimer.stop(); + } + state.removePropertyChangeListener(treeListener); + state.removePropertyChangeListener(analysisListener); + state.removePropertyChangeListener(inputListener); + super.removeNotify(); + } + + private final class WorkspaceTab extends JButton { + private final String tabId; + private boolean active; + + private WorkspaceTab(String tabId, String text) { + super(text); + this.tabId = tabId; + setFont(Fonts.BODY_MEDIUM); + setForeground(Colors.TEXT_SECONDARY); + setFocusPainted(false); + setBorderPainted(false); + setContentAreaFilled(false); + setOpaque(false); + setRolloverEnabled(true); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setBorder(BorderFactory.createEmptyBorder(8, 14, 8, 14)); + } + + private String getTabId() { + return tabId; + } + + private void setActive(boolean active) { + this.active = active; + setForeground(active ? Colors.PRIMARY_BRIGHT : Colors.TEXT_SECONDARY); + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + if (active) { + g2.setColor(Colors.alpha(Colors.NAV_ACTIVE_BG, 150)); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), 7, 7); + g2.setColor(Colors.PRIMARY_BRIGHT); + g2.fillRoundRect(10, getHeight() - 3, getWidth() - 20, 2, 2, 2); + } else if (getModel().isRollover()) { + g2.setColor(Colors.alpha(Colors.PANEL_HOVER, 120)); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), 7, 7); + } + } finally { + g2.dispose(); + } + super.paintComponent(g); + } + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/view/InformationView.java b/src/main/java/com/reandroid/apkeditor/gui/view/InformationView.java new file mode 100644 index 00000000..1e53243b --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/view/InformationView.java @@ -0,0 +1,167 @@ +package com.reandroid.apkeditor.gui.view; + +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.ApkAnalysis; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.navigation.Route; +import com.reandroid.apkeditor.gui.panel.ApplicationInfoPanel; +import com.reandroid.apkeditor.gui.panel.StatisticsPanel; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; + +import javax.swing.*; +import java.awt.*; +import java.beans.PropertyChangeListener; +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Información técnica del APK cargado. + */ +public final class InformationView extends JPanel { + private final AppState state; + private final ApplicationInfoPanel information = new ApplicationInfoPanel(); + private final StatisticsPanel statistics = new StatisticsPanel(); + private final Map technicalValues = new LinkedHashMap<>(); + private final PropertyChangeListener analysisListener; + private final PropertyChangeListener inputListener; + + public InformationView(AppState state, Consumer navigate) { + super(new BorderLayout(0, 16)); + this.state = state; + setBackground(Colors.BACKGROUND); + setBorder(BorderFactory.createEmptyBorder(18, 18, 18, 18)); + + JLabel title = new JLabel(I18n.t("info.title")); + title.setFont(Fonts.TITLE); + title.setForeground(Colors.TEXT); + JLabel subtitle = new JLabel(I18n.t("info.subtitle")); + subtitle.setFont(Fonts.SMALL); + subtitle.setForeground(Colors.TEXT_MUTED); + + JPanel titles = new JPanel(new BorderLayout(0, 3)); + titles.setOpaque(false); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + + NeonButton open = new NeonButton(I18n.t("info.openWorkspace"), + IconFactory.icon("code", 14, Colors.BACKGROUND)); + open.setVariant(NeonButton.Variant.PRIMARY); + open.addActionListener(e -> navigate.accept(Route.DECOMPILE)); + + JPanel header = new JPanel(new BorderLayout(12, 0)); + header.setOpaque(false); + header.add(titles, BorderLayout.WEST); + header.add(open, BorderLayout.EAST); + add(header, BorderLayout.NORTH); + + JPanel grid = new JPanel(new GridBagLayout()); + grid.setOpaque(false); + GridBagConstraints c = new GridBagConstraints(); + c.gridy = 0; + c.fill = GridBagConstraints.BOTH; + c.weighty = 1.0; + + c.gridx = 0; + c.weightx = 0.34; + c.insets = new Insets(0, 0, 0, 14); + grid.add(information, c); + + c.gridx = 1; + c.weightx = 0.32; + grid.add(statistics, c); + + c.gridx = 2; + c.weightx = 0.34; + c.insets = new Insets(0, 0, 0, 0); + grid.add(createTechnical(), c); + add(grid, BorderLayout.CENTER); + + analysisListener = event -> SwingUtilities.invokeLater( + () -> applyAnalysis((ApkAnalysis) event.getNewValue())); + inputListener = event -> SwingUtilities.invokeLater(this::applyInput); + state.addPropertyChangeListener(AppState.ANALYSIS_CHANGED, analysisListener); + state.addPropertyChangeListener(AppState.INPUT_CHANGED, inputListener); + applyAnalysis(state.getAnalysis()); + applyInput(); + } + + private JComponent createTechnical() { + PanelCard card = new PanelCard(I18n.t("info.detailTitle"), "binary"); + card.setSubtitle(I18n.t("info.detailSubtitle")); + + JPanel body = card.getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + for (String key : new String[]{ + I18n.t("info.field.file"), + I18n.t("info.field.path"), + I18n.t("info.field.size"), + I18n.t("info.field.files"), + I18n.t("info.field.versionCode"), + I18n.t("info.field.classes"), + I18n.t("info.field.permissions")}) { + addRow(body, key); + } + body.add(Box.createVerticalGlue()); + return card; + } + + private void addRow(JPanel body, String key) { + JPanel row = new JPanel(new BorderLayout(8, 0)); + row.setOpaque(false); + row.setBorder(BorderFactory.createEmptyBorder(3, 0, 3, 0)); + row.setAlignmentX(LEFT_ALIGNMENT); + + JLabel label = new JLabel(key); + label.setFont(Fonts.SMALL); + label.setForeground(Colors.TEXT_MUTED); + + JLabel value = new JLabel("—"); + value.setFont(Fonts.SMALL); + value.setForeground(Colors.TEXT); + value.setHorizontalAlignment(SwingConstants.RIGHT); + + row.add(label, BorderLayout.WEST); + row.add(value, BorderLayout.CENTER); + technicalValues.put(key, value); + body.add(row); + } + + private void applyInput() { + File input = state.getCurrentInput(); + set(I18n.t("info.field.file"), input == null + ? I18n.t("info.demoFile") : input.getName()); + set(I18n.t("info.field.path"), input == null ? "—" : input.getAbsolutePath()); + applyAnalysis(state.getAnalysis()); + } + + private void applyAnalysis(ApkAnalysis analysis) { + information.setAnalysis(analysis); + statistics.setAnalysis(analysis); + set(I18n.t("info.field.size"), analysis.getSizeLabel()); + set(I18n.t("info.field.files"), String.valueOf(analysis.getFileCount())); + set(I18n.t("info.field.versionCode"), String.valueOf(analysis.getVersionCode())); + set(I18n.t("info.field.classes"), String.valueOf(analysis.getClasses())); + set(I18n.t("info.field.permissions"), String.valueOf(analysis.getPermissions())); + } + + private void set(String key, String value) { + JLabel label = technicalValues.get(key); + if (label != null) { + label.setText(value); + label.setToolTipText(value); + } + } + + @Override + public void removeNotify() { + state.removePropertyChangeListener(analysisListener); + state.removePropertyChangeListener(inputListener); + super.removeNotify(); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/view/LogsView.java b/src/main/java/com/reandroid/apkeditor/gui/view/LogsView.java new file mode 100644 index 00000000..2691737a --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/view/LogsView.java @@ -0,0 +1,31 @@ +package com.reandroid.apkeditor.gui.view; + +import com.reandroid.apkeditor.gui.components.LogConsole; +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.theme.*; + +import javax.swing.*; +import java.awt.*; + +public final class LogsView extends JPanel { + public LogsView(LogConsole console) { + setLayout(new BorderLayout(0, 10)); + setBackground(Colors.BACKGROUND); + setBorder(Theme.empty(24)); + + JPanel header = Theme.panel(new BorderLayout()); + JLabel title = new JLabel(I18n.t("logs.title")); + title.setFont(Fonts.TITLE); + title.setForeground(Colors.TEXT); + + NeonButton clear = new NeonButton(I18n.t("logs.clear")); + clear.addActionListener(e -> console.clear()); + + header.add(title, BorderLayout.WEST); + header.add(clear, BorderLayout.EAST); + + add(header, BorderLayout.NORTH); + add(console, BorderLayout.CENTER); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/view/OperationView.java b/src/main/java/com/reandroid/apkeditor/gui/view/OperationView.java new file mode 100644 index 00000000..c812be01 --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/view/OperationView.java @@ -0,0 +1,347 @@ +package com.reandroid.apkeditor.gui.view; + +import com.reandroid.apkeditor.core.LegacyOptionsOperation; +import com.reandroid.apkeditor.core.Operation; +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.log.LogPanel; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.panel.AnalysisProgressPanel; +import com.reandroid.apkeditor.gui.service.LogService; +import com.reandroid.apkeditor.gui.service.OperationService; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.IconFactory; +import com.reandroid.apkeditor.gui.theme.Theme; + +import javax.swing.*; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.*; +import java.io.File; +import java.util.Locale; + +/** + * Vista genérica de operaciones del motor. El formulario y el estado son + * independientes de la implementación concreta de cada operación. + */ +public final class OperationView extends JPanel { + private final String operationId; + private final Operation operation; + private final AppState state; + private final OperationService operations; + private final LogService logs; + + private final JTextField inputField = new JTextField(); + private final JTextField outputField = new JTextField(); + private final NeonButton runButton = new NeonButton(I18n.t("op.run")); + private final AnalysisProgressPanel progressPanel = + new AnalysisProgressPanel(new String[]{ + I18n.t("op.stepPreparing"), + I18n.t("op.stepRunning"), + I18n.t("op.stepFinishing")}); + private final LogPanel logPanel; + + private boolean inputDirectory; + private boolean outputDirectory; + private boolean running; + private Timer activityTimer; + private final int[] activity = {5}; + + public OperationView(String operationId, Operation operation, AppState state, + OperationService operations, LogService logs) { + super(new BorderLayout(0, 16)); + this.operationId = operationId; + this.operation = operation; + this.state = state; + this.operations = operations; + this.logs = logs; + this.logPanel = new LogPanel(logs); + + setBackground(Colors.BACKGROUND); + setBorder(BorderFactory.createEmptyBorder(18, 18, 18, 18)); + + add(createHeader(), BorderLayout.NORTH); + add(createBody(), BorderLayout.CENTER); + prefill(); + } + + private JComponent createHeader() { + JLabel title = new JLabel(titleFor(operationId)); + title.setFont(Fonts.TITLE); + title.setForeground(Colors.TEXT); + + JLabel subtitle = new JLabel(descriptionFor(operationId)); + subtitle.setFont(Fonts.SMALL); + subtitle.setForeground(Colors.TEXT_MUTED); + + JPanel heading = new JPanel(new BorderLayout(0, 3)); + heading.setOpaque(false); + heading.add(title, BorderLayout.NORTH); + heading.add(subtitle, BorderLayout.SOUTH); + return heading; + } + + private JComponent createBody() { + JPanel container = new JPanel(new BorderLayout(0, 14)); + container.setOpaque(false); + + JPanel columns = new JPanel(new GridBagLayout()); + columns.setOpaque(false); + + GridBagConstraints c = new GridBagConstraints(); + c.gridy = 0; + c.fill = GridBagConstraints.BOTH; + c.weighty = 1.0; + + c.gridx = 0; + c.weightx = 0.40; + c.insets = new Insets(0, 0, 0, 14); + columns.add(createForm(), c); + + c.gridx = 1; + c.weightx = 0.60; + c.insets = new Insets(0, 0, 0, 0); + logPanel.setMinimumSize(new Dimension(320, 220)); + columns.add(logPanel, c); + + progressPanel.setPreferredSize(new Dimension(0, 158)); + progressPanel.setMinimumSize(new Dimension(0, 158)); + + container.add(columns, BorderLayout.CENTER); + container.add(progressPanel, BorderLayout.SOUTH); + return container; + } + + private JComponent createForm() { + PanelCard form = new PanelCard(I18n.t("op.formTitle"), "settings"); + form.setSubtitle(I18n.t("op.formSubtitle", operationId)); + + JPanel body = form.getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + + inputDirectory = "build".equals(operationId); + outputDirectory = false; + + body.add(fieldBlock(inputDirectory + ? I18n.t("op.inputDirectory") : I18n.t("op.inputApk"), + inputField, inputDirectory)); + body.add(Box.createVerticalStrut(12)); + body.add(fieldBlock(I18n.t("op.output"), outputField, outputDirectory)); + body.add(Box.createVerticalStrut(16)); + + JLabel hint = new JLabel("" + I18n.t("op.hint") + ""); + hint.setFont(Fonts.SMALL); + hint.setForeground(Colors.TEXT_MUTED); + hint.setAlignmentX(LEFT_ALIGNMENT); + body.add(hint); + body.add(Box.createVerticalStrut(16)); + + JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0)); + actions.setOpaque(false); + actions.setAlignmentX(LEFT_ALIGNMENT); + + runButton.setVariant(NeonButton.Variant.PRIMARY); + runButton.setIcon(IconFactory.icon("play", 13, Colors.BACKGROUND)); + runButton.addActionListener(e -> run()); + NeonButton reset = new NeonButton(I18n.t("op.reset")); + reset.setVariant(NeonButton.Variant.GHOST); + reset.addActionListener(e -> prefill()); + + actions.add(runButton); + actions.add(reset); + body.add(actions); + return form; + } + + private JComponent fieldBlock(String labelText, JTextField field, boolean directory) { + JPanel block = new JPanel(new BorderLayout(0, 5)); + block.setOpaque(false); + block.setAlignmentX(LEFT_ALIGNMENT); + + JLabel label = new JLabel(labelText); + label.setFont(Fonts.SMALL); + label.setForeground(Colors.TEXT_MUTED); + + field.setFont(Fonts.BODY); + field.setBackground(Colors.BACKGROUND_SECONDARY); + field.setForeground(Colors.TEXT); + field.setCaretColor(Colors.PRIMARY); + field.setSelectionColor(Colors.PANEL_HOVER); + field.setSelectedTextColor(Colors.PRIMARY_BRIGHT); + field.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(Colors.BORDER), + BorderFactory.createEmptyBorder(7, 9, 7, 9))); + field.setToolTipText(directory + ? I18n.t("op.tooltipDirectory") : I18n.t("op.tooltipFile")); + + NeonButton browse = new NeonButton(I18n.t("op.browse")); + browse.setVariant(NeonButton.Variant.GHOST); + browse.setIcon(IconFactory.icon("folder-open", 14, Colors.TEXT_SECONDARY)); + browse.addActionListener(e -> browse(field, directory)); + + JPanel line = new JPanel(new BorderLayout(7, 0)); + line.setOpaque(false); + line.add(field, BorderLayout.CENTER); + line.add(browse, BorderLayout.EAST); + + block.add(label, BorderLayout.NORTH); + block.add(line, BorderLayout.CENTER); + return block; + } + + private void browse(JTextField field, boolean directory) { + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(directory + ? JFileChooser.DIRECTORIES_ONLY : JFileChooser.FILES_ONLY); + if (!directory) { + chooser.setFileFilter(new FileNameExtensionFilter( + I18n.t("apk.filter"), "apk", "zip", "aab")); + } + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + field.setText(chooser.getSelectedFile().getAbsolutePath()); + } + } + + private void prefill() { + if (inputField.getText().isBlank()) { + File input = state.getCurrentInput(); + if (input != null && !"build".equals(operationId)) { + inputField.setText(input.getAbsolutePath()); + } + } + File output = state.getCurrentOutput(); + if (output != null && outputField.getText().isBlank()) { + outputField.setText(output.getAbsolutePath()); + } + } + + private void run() { + if (running) { + logs.warning(I18n.t("op.alreadyRunning")); + return; + } + File input = readFile(inputField); + if (input == null) { + logs.warning(I18n.t("op.selectInput")); + inputField.requestFocusInWindow(); + return; + } + File output = readFile(outputField); + + if (operation instanceof LegacyOptionsOperation legacy) { + legacy.getOptions().inputFile = input; + legacy.getOptions().outputFile = output; + } + + running = true; + runButton.setEnabled(false); + progressPanel.resetForRun(); + progressPanel.setDescription(I18n.t("op.executing", operation.getName())); + logs.info(I18n.t("op.requested", operation.getName())); + + activity[0] = 5; + if (activityTimer != null) { + activityTimer.stop(); + } + activityTimer = new Timer(420, e -> { + if (!running) { + activityTimer.stop(); + return; + } + activity[0] = Math.min(90, activity[0] + 1); + updateProgress(activity[0]); + }); + activityTimer.start(); + + boolean started = operations.execute(operationId, input, output, + busy -> { + if (busy) { + state.setBusy(true); + } + }, + this::updateProgress, + (success, error) -> { + running = false; + if (activityTimer != null) { + activityTimer.stop(); + } + runButton.setEnabled(true); + state.setBusy(false); + progressPanel.finish(success); + if (success && operation instanceof LegacyOptionsOperation legacy + && legacy.getOptions().outputFile != null) { + state.setCurrentOutput(legacy.getOptions().outputFile); + } + progressPanel.setDescription(I18n.t(success + ? "op.completed" : "op.failed")); + }); + if (!started) { + running = false; + if (activityTimer != null) { + activityTimer.stop(); + } + runButton.setEnabled(true); + state.setBusy(false); + progressPanel.finish(false); + } + } + + private void updateProgress(int percent) { + progressPanel.setProgress(percent); + if (percent < 20) { + step(0); + } else if (percent < 88) { + step(1); + } else { + step(2); + } + } + + private void step(int active) { + for (int i = 0; i < 3; i++) { + if (i < active) { + progressPanel.setStepStatus(i, AnalysisProgressPanel.StepStatus.COMPLETED); + } else if (i == active) { + progressPanel.setStepStatus(i, AnalysisProgressPanel.StepStatus.ACTIVE); + } else { + progressPanel.setStepStatus(i, AnalysisProgressPanel.StepStatus.PENDING); + } + } + } + + private File readFile(JTextField field) { + String text = field.getText().trim(); + return text.isEmpty() ? null : new File(text); + } + + private String titleFor(String id) { + return switch (id.toLowerCase(Locale.ROOT)) { + case "build" -> I18n.t("op.title.build"); + case "merge" -> I18n.t("op.title.merge"); + case "refactor" -> I18n.t("op.title.refactor"); + case "protect" -> I18n.t("op.title.protect"); + case "info" -> I18n.t("op.title.info"); + default -> operation.getName(); + }; + } + + private String descriptionFor(String id) { + return switch (id.toLowerCase(Locale.ROOT)) { + case "build" -> I18n.t("op.desc.build"); + case "merge" -> I18n.t("op.desc.merge"); + case "refactor" -> I18n.t("op.desc.refactor"); + case "protect" -> I18n.t("op.desc.protect"); + case "info" -> I18n.t("op.desc.info"); + default -> I18n.t("op.desc.default"); + }; + } + + @Override + public void removeNotify() { + if (activityTimer != null) { + activityTimer.stop(); + } + super.removeNotify(); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/gui/view/SettingsView.java b/src/main/java/com/reandroid/apkeditor/gui/view/SettingsView.java new file mode 100644 index 00000000..ba2b9c2d --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/gui/view/SettingsView.java @@ -0,0 +1,291 @@ +package com.reandroid.apkeditor.gui.view; + +import com.reandroid.apkeditor.core.OperationRegistry; +import com.reandroid.apkeditor.gui.components.NeonButton; +import com.reandroid.apkeditor.gui.components.PanelCard; +import com.reandroid.apkeditor.gui.i18n.I18n; +import com.reandroid.apkeditor.gui.model.AppState; +import com.reandroid.apkeditor.gui.theme.Colors; +import com.reandroid.apkeditor.gui.theme.Fonts; +import com.reandroid.apkeditor.gui.theme.Theme; + +import javax.swing.*; +import java.awt.*; +import java.beans.PropertyChangeListener; +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Configuración de apariencia y rutas de trabajo. Los cambios de tema se + * publican en AppState y la ventana reconstruye la interfaz. + */ +public final class SettingsView extends JPanel { + private final AppState state; + private final Map pathValues = new LinkedHashMap<>(); + private final PropertyChangeListener inputListener; + private final PropertyChangeListener outputListener; + + public SettingsView(AppState state, OperationRegistry registry) { + super(new BorderLayout(0, 16)); + this.state = state; + setBackground(Colors.BACKGROUND); + setBorder(BorderFactory.createEmptyBorder(18, 18, 18, 18)); + + JLabel title = new JLabel(I18n.t("settings.title")); + title.setFont(Fonts.TITLE); + title.setForeground(Colors.TEXT); + JLabel subtitle = new JLabel(I18n.t("settings.subtitle")); + subtitle.setFont(Fonts.SMALL); + subtitle.setForeground(Colors.TEXT_MUTED); + + JPanel titles = new JPanel(new BorderLayout(0, 3)); + titles.setOpaque(false); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + add(titles, BorderLayout.NORTH); + + JPanel grid = new JPanel(new GridBagLayout()); + grid.setOpaque(false); + GridBagConstraints c = new GridBagConstraints(); + c.gridy = 0; + c.fill = GridBagConstraints.BOTH; + c.weighty = 0.52; + + c.gridx = 0; + c.weightx = 0.34; + c.insets = new Insets(0, 0, 0, 14); + grid.add(createAppearance(), c); + + c.gridx = 1; + c.weightx = 0.32; + grid.add(createPaths(), c); + + c.gridx = 2; + c.weightx = 0.34; + c.insets = new Insets(0, 0, 0, 0); + grid.add(createEngine(registry), c); + + c.gridy = 1; + c.gridx = 0; + c.gridwidth = 3; + c.weightx = 1.0; + c.weighty = 0.48; + c.insets = new Insets(14, 0, 0, 0); + grid.add(createShortcuts(), c); + + add(grid, BorderLayout.CENTER); + + inputListener = event -> SwingUtilities.invokeLater(this::applyInput); + outputListener = event -> SwingUtilities.invokeLater(this::applyOutput); + state.addPropertyChangeListener(AppState.INPUT_CHANGED, inputListener); + state.addPropertyChangeListener(AppState.OUTPUT_CHANGED, outputListener); + applyInput(); + applyOutput(); + } + + private JComponent createAppearance() { + PanelCard card = new PanelCard(I18n.t("settings.appearance"), "settings"); + card.setSubtitle(I18n.t("settings.themeSubtitle", Theme.current())); + + JPanel themes = new JPanel(new GridLayout(2, 2, 8, 8)); + themes.setOpaque(false); + for (String name : Theme.NAMES) { + NeonButton button = new NeonButton(name); + button.setVariant(name.equals(state.getThemeName()) + ? NeonButton.Variant.PRIMARY : NeonButton.Variant.SECONDARY); + button.addActionListener(e -> state.setThemeName(name)); + themes.add(button); + } + + JLabel languageLabel = new JLabel(I18n.t("settings.language")); + languageLabel.setFont(Fonts.SMALL); + languageLabel.setForeground(Colors.TEXT_SECONDARY); + + JPanel languages = new JPanel(new GridLayout(1, 2, 8, 8)); + languages.setOpaque(false); + for (I18n.Language language : I18n.Language.values()) { + NeonButton button = new NeonButton(language.getLabel()); + button.setFont(Fonts.SMALL); + button.setVariant(language.getCode().equals(state.getLanguageCode()) + ? NeonButton.Variant.PRIMARY : NeonButton.Variant.SECONDARY); + button.addActionListener(e -> state.setLanguageCode(language.getCode())); + languages.add(button); + } + + JLabel themeHint = new JLabel("" + I18n.t("settings.themeHint") + ""); + themeHint.setFont(Fonts.SMALL); + themeHint.setForeground(Colors.TEXT_MUTED); + + JLabel languageHint = new JLabel(I18n.t("settings.languageHint")); + languageHint.setFont(Fonts.SMALL); + languageHint.setForeground(Colors.TEXT_MUTED); + + JPanel languageBlock = new JPanel(new BorderLayout(0, 6)); + languageBlock.setOpaque(false); + languageBlock.add(languageLabel, BorderLayout.NORTH); + languageBlock.add(languages, BorderLayout.CENTER); + + JPanel texts = new JPanel(); + texts.setOpaque(false); + texts.setLayout(new BoxLayout(texts, BoxLayout.Y_AXIS)); + themeHint.setAlignmentX(LEFT_ALIGNMENT); + languageBlock.setAlignmentX(LEFT_ALIGNMENT); + languageHint.setAlignmentX(LEFT_ALIGNMENT); + texts.add(themeHint); + texts.add(Box.createVerticalStrut(12)); + texts.add(languageBlock); + texts.add(Box.createVerticalStrut(4)); + texts.add(languageHint); + + JPanel body = card.getBody(); + body.setLayout(new BorderLayout(0, 12)); + body.add(themes, BorderLayout.NORTH); + body.add(texts, BorderLayout.CENTER); + return card; + } + + private JComponent createPaths() { + PanelCard card = new PanelCard(I18n.t("settings.paths"), "folder-open"); + card.setSubtitle(I18n.t("settings.currentSession")); + + JPanel body = card.getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + addPathRow(body, I18n.t("settings.input")); + addPathRow(body, I18n.t("settings.output")); + addPathRow(body, I18n.t("settings.workspace")); + body.add(Box.createVerticalGlue()); + return card; + } + + private void addPathRow(JPanel body, String key) { + JPanel row = new JPanel(new BorderLayout(8, 0)); + row.setOpaque(false); + row.setBorder(BorderFactory.createEmptyBorder(3, 0, 3, 0)); + row.setAlignmentX(LEFT_ALIGNMENT); + + JLabel label = new JLabel(key); + label.setFont(Fonts.SMALL); + label.setForeground(Colors.TEXT_MUTED); + + JLabel value = new JLabel("—"); + value.setFont(Fonts.SMALL); + value.setForeground(Colors.TEXT); + value.setHorizontalAlignment(SwingConstants.RIGHT); + + row.add(label, BorderLayout.WEST); + row.add(value, BorderLayout.CENTER); + pathValues.put(key, value); + body.add(row); + } + + private JComponent createEngine(OperationRegistry registry) { + PanelCard card = new PanelCard(I18n.t("settings.engine"), "layers"); + card.setSubtitle("APKEditor core"); + + JPanel body = card.getBody(); + body.setLayout(new BoxLayout(body, BoxLayout.Y_AXIS)); + addStaticRow(body, I18n.t("settings.version"), "1.4.9"); + addStaticRow(body, I18n.t("settings.operations"), + String.valueOf(registry.all().size())); + addStaticRow(body, I18n.t("settings.java"), + System.getProperty("java.version", "—")); + addStaticRow(body, I18n.t("settings.interface"), "Swing · Java2D"); + addStaticRow(body, I18n.t("settings.editor"), "Smali highlighter"); + body.add(Box.createVerticalGlue()); + return card; + } + + private void addStaticRow(JPanel body, String key, String value) { + JPanel row = new JPanel(new BorderLayout(8, 0)); + row.setOpaque(false); + row.setBorder(BorderFactory.createEmptyBorder(3, 0, 3, 0)); + row.setAlignmentX(LEFT_ALIGNMENT); + + JLabel label = new JLabel(key); + label.setFont(Fonts.SMALL); + label.setForeground(Colors.TEXT_MUTED); + + JLabel labelValue = new JLabel(value); + labelValue.setFont(Fonts.SMALL); + labelValue.setForeground(Colors.TEXT); + labelValue.setHorizontalAlignment(SwingConstants.RIGHT); + + row.add(label, BorderLayout.WEST); + row.add(labelValue, BorderLayout.CENTER); + body.add(row); + } + + private JComponent createShortcuts() { + PanelCard card = new PanelCard(I18n.t("settings.shortcuts"), "terminal"); + card.setSubtitle(I18n.t("settings.productivity")); + + JPanel grid = new JPanel(new GridLayout(2, 4, 12, 6)); + grid.setOpaque(false); + addShortcut(grid, "F5", I18n.t("settings.shortcut.decompile")); + addShortcut(grid, "Ctrl + O", I18n.t("settings.shortcut.selectApk")); + addShortcut(grid, "Ctrl + F", I18n.t("settings.shortcut.search")); + addShortcut(grid, "Ctrl + G", I18n.t("settings.shortcut.gotoLine")); + addShortcut(grid, "Ctrl + S", I18n.t("settings.shortcut.save")); + addShortcut(grid, "Ctrl + W", I18n.t("settings.shortcut.closeTab")); + addShortcut(grid, "Ctrl + 1", I18n.t("settings.shortcut.workspace")); + addShortcut(grid, "Esc", I18n.t("settings.shortcut.escape")); + + card.getBody().setLayout(new BorderLayout()); + card.getBody().add(grid, BorderLayout.CENTER); + return card; + } + + private void addShortcut(JPanel parent, String key, String description) { + JPanel row = new JPanel(new BorderLayout(8, 0)); + row.setOpaque(false); + JLabel keyLabel = new JLabel(key); + keyLabel.setFont(Fonts.MONO_SMALL); + keyLabel.setForeground(Colors.PRIMARY_BRIGHT); + JLabel descriptionLabel = new JLabel(description); + descriptionLabel.setFont(Fonts.SMALL); + descriptionLabel.setForeground(Colors.TEXT_MUTED); + row.add(keyLabel, BorderLayout.WEST); + row.add(descriptionLabel, BorderLayout.CENTER); + parent.add(row); + } + + private void applyInput() { + File input = state.getCurrentInput(); + setPath(I18n.t("settings.input"), input == null + ? I18n.t("settings.demoApk") : input.getAbsolutePath()); + setPath(I18n.t("settings.workspace"), input == null + ? I18n.t("settings.demoWorkspace") : input.getParent()); + } + + private void applyOutput() { + File output = state.getCurrentOutput(); + setPath(I18n.t("settings.output"), output == null + ? I18n.t("settings.auto") : output.getAbsolutePath()); + } + + private void setPath(String key, String value) { + JLabel label = pathValues.get(key); + if (label != null) { + label.setText(shorten(value)); + label.setToolTipText(value); + } + } + + private String shorten(String value) { + if (value == null) { + return "—"; + } + if (value.length() <= 30) { + return value; + } + return "…" + value.substring(value.length() - 29); + } + + @Override + public void removeNotify() { + state.removePropertyChangeListener(inputListener); + state.removePropertyChangeListener(outputListener); + super.removeNotify(); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/info/Info.java b/src/main/java/com/reandroid/apkeditor/info/Info.java index 8ceb6ede..44426fa6 100644 --- a/src/main/java/com/reandroid/apkeditor/info/Info.java +++ b/src/main/java/com/reandroid/apkeditor/info/Info.java @@ -172,7 +172,8 @@ private void printRes(ApkModule apkModule, String res) throws IOException { if(res.startsWith("@0x")){ res = res.substring(1); } - EncodeResult encodeResult = ValueCoder.encode(res, AttributeDataFormat.INTEGER.valueTypes()); + EncodeResult encodeResult = ValueCoder.encode(res, + AttributeDataFormat.INTEGER.dataTypes()); if(encodeResult != null){ printEntries(apkModule, "resource", encodeResult.value); return; @@ -250,8 +251,8 @@ private void printAppName(ApkModule apkModule) throws IOException { if(attributeLabel == null){ return; } - if(attributeLabel.getValueType() == ValueType.STRING){ - getInfoWriter().writeNameValue("AppName", attributeLabel.getValueAsString()); + if(attributeLabel.getDataType() == DataType.STRING){ + getInfoWriter().writeNameValue("AppName", attributeLabel.getDataAsString()); return; } int resourceId = attributeLabel.getData(); @@ -269,8 +270,8 @@ private void printAppIcon(ApkModule apkModule) throws IOException { if(attribute == null){ return; } - if(attribute.getValueType() == ValueType.STRING){ - getInfoWriter().writeNameValue("AppIcon", attribute.getValueAsString()); + if(attribute.getDataType() == DataType.STRING){ + getInfoWriter().writeNameValue("AppIcon", attribute.getDataAsString()); return; } int resourceId = attribute.getData(); @@ -289,8 +290,8 @@ private void printAppRoundIcon(ApkModule apkModule) throws IOException { if(attribute == null){ return; } - if(attribute.getValueType() == ValueType.STRING){ - getInfoWriter().writeNameValue("AppRoundIcon", attribute.getValueAsString()); + if(attribute.getDataType() == DataType.STRING){ + getInfoWriter().writeNameValue("AppRoundIcon", attribute.getDataAsString()); return; } int resourceId = attribute.getData(); @@ -473,7 +474,7 @@ private String getValueOfName(ResXmlElement element){ if(attribute == null){ return null; } - return attribute.getValueAsString(); + return attribute.getDataAsString(); } private void printEntries(ApkModule apkModule, String varName, int resourceId) throws IOException { TableBlock tableBlock = apkModule.getTableBlock(); @@ -501,9 +502,9 @@ private String getValueAsString(Entry entry){ if(resValue == null){ return ""; } - ValueType valueType = resValue.getValueType(); - if(valueType == ValueType.STRING){ - return resValue.getValueAsString(); + DataType valueType = resValue.getDataType(); + if(valueType == DataType.STRING){ + return resValue.getDataAsString(); } String decoded = ValueCoder.decode(valueType, resValue.getData()); if(decoded != null){ diff --git a/src/main/java/com/reandroid/apkeditor/info/InfoWriter.java b/src/main/java/com/reandroid/apkeditor/info/InfoWriter.java index 67b2bc2a..a51c0736 100644 --- a/src/main/java/com/reandroid/apkeditor/info/InfoWriter.java +++ b/src/main/java/com/reandroid/apkeditor/info/InfoWriter.java @@ -23,16 +23,21 @@ import com.reandroid.arsc.container.SpecTypePair; import com.reandroid.arsc.model.ResourceEntry; import com.reandroid.arsc.pool.StringPool; +import com.reandroid.arsc.value.DataType; +import com.reandroid.arsc.value.Entry; +import com.reandroid.arsc.value.Value; import com.reandroid.dex.model.DexDirectory; import com.reandroid.dex.model.DexFile; import com.reandroid.utils.HexUtil; -import com.reandroid.arsc.value.*; import com.reandroid.utils.collection.CollectionUtil; import java.io.Closeable; import java.io.IOException; import java.io.Writer; -import java.util.*; +import java.util.Base64; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; public abstract class InfoWriter implements Closeable { private final Writer writer; @@ -90,19 +95,19 @@ static String toString(Object obj){ } return null; } - static String getValueAsString(Value value){ - ValueType valueType = value.getValueType(); - if(valueType == ValueType.STRING){ - return value.getValueAsString(); + static String getDataAsString(Value value){ + DataType dataType = value.getDataType(); + if(dataType == DataType.STRING){ + return value.getDataAsString(); } - String decoded = ValueCoder.decode(valueType, value.getData()); + String decoded = ValueCoder.decode(dataType, value.getData()); if(decoded != null){ return decoded; } - if(valueType == ValueType.ATTRIBUTE){ + if(dataType == DataType.ATTRIBUTE){ return HexUtil.toHex8("?0x", value.getData()); } - if(valueType == ValueType.REFERENCE){ + if(dataType == DataType.REFERENCE){ return HexUtil.toHex8("@0x", value.getData()); } return HexUtil.toHex8("0x", value.getData()); diff --git a/src/main/java/com/reandroid/apkeditor/info/InfoWriterJson.java b/src/main/java/com/reandroid/apkeditor/info/InfoWriterJson.java index 7ca9edb3..e7d6bb9c 100644 --- a/src/main/java/com/reandroid/apkeditor/info/InfoWriterJson.java +++ b/src/main/java/com/reandroid/apkeditor/info/InfoWriterJson.java @@ -164,7 +164,7 @@ private void writeResEntry(Entry entry) throws IOException { } mJsonWriter.object() .key(NAME_QUALIFIERS).value(entry.getResConfig().getQualifiers()) - .key("value").value(getValueAsString(resValue)) + .key("value").value(getDataAsString(resValue)) .endObject(); } private void writeBagEntry(Entry entry) { @@ -184,7 +184,7 @@ private void writeValueMap(ResValueMap resValueMap){ mJsonWriter.object() .key("name").value(resValueMap.decodeName()) .key("id").value(resValueMap.getNameId()) - .key("value").value(getValueAsString(resValueMap)) + .key("value").value(getDataAsString(resValueMap)) .endObject(); } @Override diff --git a/src/main/java/com/reandroid/apkeditor/info/InfoWriterText.java b/src/main/java/com/reandroid/apkeditor/info/InfoWriterText.java index cac9c9fd..4a665a80 100644 --- a/src/main/java/com/reandroid/apkeditor/info/InfoWriterText.java +++ b/src/main/java/com/reandroid/apkeditor/info/InfoWriterText.java @@ -163,21 +163,21 @@ private void writeAttribute(int indent, ResXmlAttribute attribute) throws IOExce writer.append(')'); } writer.append('='); - ValueType valueType = attribute.getValueType(); - if (valueType == ValueType.STRING) { + DataType dataType = attribute.getDataType(); + if (dataType == DataType.STRING) { writer.append('"'); writer.write(attribute.getDataAsPoolString().getXml()); writer.append('"'); writer.write(" (Raw: \""); writer.write(attribute.getValueString()); writer.write("\")"); - } else if (valueType == ValueType.BOOLEAN) { + } else if (dataType == DataType.BOOLEAN) { writer.append('"'); - writer.write(attribute.getValueAsBoolean() ? "true" : "false"); + writer.write(attribute.getDataAsBoolean() ? "true" : "false"); writer.append('"'); } else { writer.write("(type "); - writer.write(HexUtil.toHex(valueType.getByte() & 0xff, 1)); + writer.write(HexUtil.toHex(dataType.flag(), 1)); writer.write(")"); writer.write(HexUtil.toHex(attribute.getData(), 1)); } @@ -329,7 +329,7 @@ private void writeResEntry(Entry entry) throws IOException { return; } Writer writer = getWriter(); - writer.write(getValueAsString(resValue)); + writer.write(getDataAsString(resValue)); writer.write("\n"); } private void writeBagEntry(Entry entry) throws IOException { @@ -357,7 +357,7 @@ private void writeValueMap(ResValueMap resValueMap) throws IOException { writer.write(")"); } writer.write("="); - writer.write(getValueAsString(resValueMap)); + writer.write(getDataAsString(resValueMap)); writer.write("\n"); } @Override diff --git a/src/main/java/com/reandroid/apkeditor/info/InfoWriterXml.java b/src/main/java/com/reandroid/apkeditor/info/InfoWriterXml.java index 9d3f7467..69a901f7 100644 --- a/src/main/java/com/reandroid/apkeditor/info/InfoWriterXml.java +++ b/src/main/java/com/reandroid/apkeditor/info/InfoWriterXml.java @@ -303,8 +303,8 @@ private void writeResEntry(Entry entry) throws IOException { mIndent = indent; writeIndent(serializer, indent); serializer.startTag(null, TAG_VALUE); - serializer.attribute(null, "type", resValue.getValueType().name()); - serializer.text(getValueAsString(resValue)); + serializer.attribute(null, "type", resValue.getDataType().name()); + serializer.text(getDataAsString(resValue)); serializer.endTag(null, TAG_VALUE); indent = indent - 2; mIndent = indent; @@ -334,8 +334,8 @@ private void writeValueMap(ResValueMap resValueMap) throws IOException { serializer.startTag(null, TAG_VALUE); serializer.attribute(null, "name", HexUtil.toHex8(resValueMap.getNameId())); - serializer.attribute(null, "type", resValueMap.getValueType().name()); - serializer.text(getValueAsString(resValueMap)); + serializer.attribute(null, "type", resValueMap.getDataType().name()); + serializer.text(getDataAsString(resValueMap)); serializer.endTag(null, TAG_VALUE); indent = indent - 2; mIndent = indent; diff --git a/src/main/java/com/reandroid/apkeditor/merge/Merger.java b/src/main/java/com/reandroid/apkeditor/merge/Merger.java index 62d96d6a..0ffc48c4 100644 --- a/src/main/java/com/reandroid/apkeditor/merge/Merger.java +++ b/src/main/java/com/reandroid/apkeditor/merge/Merger.java @@ -33,7 +33,7 @@ import com.reandroid.arsc.chunk.xml.ResXmlAttribute; import com.reandroid.arsc.chunk.xml.ResXmlElement; import com.reandroid.arsc.value.Entry; -import com.reandroid.arsc.value.ValueType; +import com.reandroid.arsc.value.DataType; import java.io.File; import java.io.IOException; @@ -177,7 +177,7 @@ private boolean removeSplitsTableEntry(ResXmlElement metaElement, ApkModule apkM if(nameAttribute == null){ return false; } - if(!"com.android.vending.splits".equals(nameAttribute.getValueAsString())){ + if(!"com.android.vending.splits".equals(nameAttribute.getDataAsString())){ return false; } ResXmlAttribute valueAttribute=metaElement.searchAttributeByResourceId( @@ -187,7 +187,7 @@ private boolean removeSplitsTableEntry(ResXmlElement metaElement, ApkModule apkM AndroidManifest.ID_resource); } if(valueAttribute == null - || valueAttribute.getValueType() != ValueType.REFERENCE){ + || valueAttribute.getDataType() != DataType.REFERENCE){ return false; } if(!apkModule.hasTableBlock()){ @@ -207,7 +207,7 @@ private boolean removeSplitsTableEntry(ResXmlElement metaElement, ApkModule apkM if(resValue == null){ continue; } - String path = resValue.getValueAsString(); + String path = resValue.getDataAsString(); logMessage("Removed-table-entry : " + path); //Remove file entry zipEntryMap.remove(path); diff --git a/src/main/java/com/reandroid/apkeditor/operations/OperationCatalog.java b/src/main/java/com/reandroid/apkeditor/operations/OperationCatalog.java new file mode 100644 index 00000000..317eb82c --- /dev/null +++ b/src/main/java/com/reandroid/apkeditor/operations/OperationCatalog.java @@ -0,0 +1,28 @@ +package com.reandroid.apkeditor.operations; + +import com.reandroid.apkeditor.core.OperationRegistry; +import com.reandroid.apkeditor.core.LegacyOptionsOperation; +import com.reandroid.apkeditor.compile.BuildOptions; +import com.reandroid.apkeditor.decompile.DecompileOptions; +import com.reandroid.apkeditor.info.InfoOptions; +import com.reandroid.apkeditor.merge.MergerOptions; +import com.reandroid.apkeditor.protect.ProtectorOptions; +import com.reandroid.apkeditor.refactor.RefactorOptions; + +/** + * Punto único para registrar las operaciones que ya existen en el motor. + * Aquí no se dibuja ninguna ventana. + */ +public final class OperationCatalog { + private OperationCatalog() {} + + public static OperationRegistry create() { + return new OperationRegistry() + .register(new LegacyOptionsOperation("decode", "Decode APK", new DecompileOptions())) + .register(new LegacyOptionsOperation("build", "Build APK", new BuildOptions())) + .register(new LegacyOptionsOperation("merge", "Merge APK", new MergerOptions())) + .register(new LegacyOptionsOperation("refactor", "Refactor", new RefactorOptions())) + .register(new LegacyOptionsOperation("protect", "Protect", new ProtectorOptions())) + .register(new LegacyOptionsOperation("info", "APK Info", new InfoOptions())); + } +} diff --git a/src/main/java/com/reandroid/apkeditor/protect/ManifestConfuser.java b/src/main/java/com/reandroid/apkeditor/protect/ManifestConfuser.java index e331df12..6fa1248b 100644 --- a/src/main/java/com/reandroid/apkeditor/protect/ManifestConfuser.java +++ b/src/main/java/com/reandroid/apkeditor/protect/ManifestConfuser.java @@ -89,7 +89,7 @@ private void confuseOffset(ResXmlElement element) { } elementApi.getStartElement() .getFirstPlaceHolder() - .setItem(byteArray); + .block(byteArray); element.refresh(); } diff --git a/src/main/java/com/reandroid/apkeditor/protect/TableConfuser.java b/src/main/java/com/reandroid/apkeditor/protect/TableConfuser.java index 42028ca8..9451a7d3 100644 --- a/src/main/java/com/reandroid/apkeditor/protect/TableConfuser.java +++ b/src/main/java/com/reandroid/apkeditor/protect/TableConfuser.java @@ -21,7 +21,7 @@ import com.reandroid.arsc.chunk.PackageBlock; import com.reandroid.arsc.chunk.TableBlock; import com.reandroid.arsc.chunk.UnknownChunk; -import com.reandroid.arsc.item.ByteArray; +import com.reandroid.arsc.item.CountedBlockItem; import com.reandroid.arsc.item.FixedLengthString; import com.reandroid.arsc.item.TypeString; import com.reandroid.arsc.pool.TypeStringPool; @@ -44,18 +44,20 @@ private void confuseWithUnknownChunk() { UnknownChunk unknownChunk = new UnknownChunk(); FixedLengthString fixedLengthString = new FixedLengthString(256); fixedLengthString.set(APKEditor.getRepo()); - ByteArray extra = unknownChunk.getHeaderBlock().getExtraBytes(); + + CountedBlockItem extra = unknownChunk.getHeaderBlock().excessBytes(); byte[] bytes = fixedLengthString.getBytes(); extra.setSize(bytes.length); - extra.putByteArray(0, bytes); + extra.write(bytes); fixedLengthString.set(ARSCLib.getRepo()); extra = unknownChunk.getBody(); bytes = fixedLengthString.getBytes(); extra.setSize(bytes.length); - extra.putByteArray(0, bytes); + extra.write(bytes); + fixedLengthString.set(ARSCLib.getRepo()); unknownChunk.refresh(); - tableBlock.getFirstPlaceHolder().setItem(unknownChunk); + tableBlock.getFirstPlaceHolder().block(unknownChunk); tableBlock.refresh(); } private void confuseTypeNames() { @@ -66,7 +68,7 @@ private void confuseTypeNames() { logMessage("Type names ..."); ApkModule apkModule = getApkModule(); TableBlock tableBlock = apkModule.getTableBlock(); - for(PackageBlock packageBlock:tableBlock.listPackages()){ + for(PackageBlock packageBlock:tableBlock){ TypeStringPool pool = packageBlock.getTypeStringPool(); for(TypeString typeString : pool) { String type = typeString.get(); diff --git a/src/main/java/com/reandroid/apkeditor/refactor/AutoRefactor.java b/src/main/java/com/reandroid/apkeditor/refactor/AutoRefactor.java index 0e2cce0f..345952c9 100644 --- a/src/main/java/com/reandroid/apkeditor/refactor/AutoRefactor.java +++ b/src/main/java/com/reandroid/apkeditor/refactor/AutoRefactor.java @@ -43,7 +43,7 @@ public int refactorFilePaths(){ int renameCount = 0; List resFileList = mApkModule.listResFiles(); for(ResFile resFile:resFileList){ - String path = resFile.buildPath(PackageBlock.RES_DIRECTORY_NAME); + String path = resFile.buildPath(PackageBlock.DIRECTORY_NAME_RES); if(path.equals(resFile.getFilePath())){ continue; } diff --git a/src/main/java/com/reandroid/apkeditor/refactor/StringValueNameGenerator.java b/src/main/java/com/reandroid/apkeditor/refactor/StringValueNameGenerator.java index c9c5d368..ffa8c6a8 100644 --- a/src/main/java/com/reandroid/apkeditor/refactor/StringValueNameGenerator.java +++ b/src/main/java/com/reandroid/apkeditor/refactor/StringValueNameGenerator.java @@ -4,9 +4,19 @@ import com.reandroid.arsc.chunk.TableBlock; import com.reandroid.arsc.container.SpecTypePair; import com.reandroid.arsc.model.ResourceEntry; -import com.reandroid.arsc.value.*; +import com.reandroid.arsc.value.DataType; +import com.reandroid.arsc.value.Entry; +import com.reandroid.arsc.value.ResTableEntry; +import com.reandroid.arsc.value.ResValue; +import com.reandroid.arsc.value.TableEntry; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -55,7 +65,7 @@ private Map generate(){ continue; } ResValue resValue = ((ResTableEntry)entry.getTableEntry()).getValue(); - String text = resValue.getValueAsString(); + String text = resValue.getDataAsString(); String name = generate(resourceId, text); if(name!=null){ results.put(resourceId, name); @@ -75,7 +85,7 @@ private Entry getEnglishOrDefault(ResourceEntry resourceEntry){ continue; } ResValue resValue = ((ResTableEntry)tableEntry).getValue(); - if(resValue.getValueType() != ValueType.STRING){ + if(resValue.getDataType() != DataType.STRING){ continue; } if(entry.getResConfig().isDefault()){ diff --git a/src/main/java/com/reandroid/apkeditor/refactor/TypeNameRefactor.java b/src/main/java/com/reandroid/apkeditor/refactor/TypeNameRefactor.java index 287ceb45..4aa30e54 100644 --- a/src/main/java/com/reandroid/apkeditor/refactor/TypeNameRefactor.java +++ b/src/main/java/com/reandroid/apkeditor/refactor/TypeNameRefactor.java @@ -29,7 +29,7 @@ import com.reandroid.arsc.value.AttributeDataFormat; import com.reandroid.arsc.value.Entry; import com.reandroid.arsc.value.ResTableMapEntry; -import com.reandroid.arsc.value.ValueType; +import com.reandroid.arsc.value.DataType; import com.reandroid.arsc.value.array.ArrayBag; import com.reandroid.arsc.value.attribute.AttributeBag; import com.reandroid.arsc.value.plurals.PluralsBag; @@ -38,7 +38,10 @@ import com.reandroid.xml.XMLElement; import java.io.IOException; -import java.util.*; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; public class TypeNameRefactor { private final ApkModule apkModule; @@ -412,7 +415,7 @@ private boolean checkColor(ResXmlAttribute attribute){ if(nameId!=textColor){ return false; } - if(attribute.getValueType() != ValueType.REFERENCE){ + if(attribute.getDataType() != DataType.REFERENCE){ return true; } rename(attribute.getData(), name); @@ -436,7 +439,7 @@ private boolean checkWithAndroidAttribute(String name, if(nameId == 0){ return false; } - if(attribute.getValueType() != ValueType.REFERENCE){ + if(attribute.getDataType() != DataType.REFERENCE){ return true; } if(!isEqualAndroidAttributeType(nameId, attributeValueType)){ @@ -456,7 +459,7 @@ private boolean checkDimen(ResXmlAttribute attribute){ if(nameId!=layout_width && nameId!=layout_height){ return false; } - if(attribute.getValueType() != ValueType.REFERENCE){ + if(attribute.getDataType() != DataType.REFERENCE){ return true; } rename(attribute.getData(), name); @@ -470,7 +473,7 @@ private boolean checkId(ResXmlAttribute attribute){ if(attribute.getNameId()!=AndroidManifest.ID_id){ return false; } - if(attribute.getValueType() != ValueType.REFERENCE){ + if(attribute.getDataType() != DataType.REFERENCE){ return true; } rename(attribute.getData(), name); @@ -484,7 +487,7 @@ private boolean checkStyle(ResXmlAttribute attribute){ if(attribute.getNameId() != AndroidManifest.ID_theme){ return false; } - if(attribute.getValueType() != ValueType.REFERENCE){ + if(attribute.getDataType() != DataType.REFERENCE){ return true; } rename(attribute.getData(), name); @@ -498,7 +501,7 @@ private boolean checkString(ResXmlAttribute attribute){ if(attribute.getNameId() != AndroidManifest.ID_label){ return false; } - if(attribute.getValueType() != ValueType.REFERENCE){ + if(attribute.getDataType() != DataType.REFERENCE){ return true; } rename(attribute.getData(), name); @@ -554,7 +557,7 @@ private boolean rename(int resourceId, String name){ typeString.set(name); return true; } - private boolean isEqualAndroidAttributeType(int attributeResourceId, AttributeDataFormat attributeValueType){ + private boolean isEqualAndroidAttributeType(int attributeResourceId, AttributeDataFormat attributeDataType){ FrameworkApk frameworkApk = AndroidFrameworks.getCurrent(); if(frameworkApk==null){ return false; @@ -580,7 +583,7 @@ private boolean isEqualAndroidAttributeType(int attributeResourceId, AttributeDa return false; } return attributeBag.getFormat() - .isEqualType(attributeValueType); + .isEqualType(attributeDataType); } private void addRefactored(int id, String name){ refactoredTypeMap.add(id, name); diff --git a/src/main/java/com/reandroid/apkeditor/smali/ResourceComment.java b/src/main/java/com/reandroid/apkeditor/smali/ResourceComment.java index 4fadaae8..0039fe86 100644 --- a/src/main/java/com/reandroid/apkeditor/smali/ResourceComment.java +++ b/src/main/java/com/reandroid/apkeditor/smali/ResourceComment.java @@ -20,7 +20,7 @@ import com.reandroid.arsc.model.ResourceEntry; import com.reandroid.arsc.value.Entry; import com.reandroid.arsc.value.ResValue; -import com.reandroid.arsc.value.ValueType; +import com.reandroid.arsc.value.DataType; import org.jf.baksmali.CommentProvider; import java.util.HashMap; @@ -62,7 +62,7 @@ private String buildComment(int resourceId) { } String ref = resourceEntry - .buildReference(packageBlock, ValueType.REFERENCE); + .buildReference(packageBlock, DataType.REFERENCE); if (resourceEntry.getPackageBlock().getTableBlock() != tableBlock) { return ref; diff --git a/src/main/java/com/reandroid/commons/utils/ElapsedTime.java b/src/main/java/com/reandroid/commons/utils/ElapsedTime.java index 7f6b6997..75443bf2 100644 --- a/src/main/java/com/reandroid/commons/utils/ElapsedTime.java +++ b/src/main/java/com/reandroid/commons/utils/ElapsedTime.java @@ -85,4 +85,4 @@ private String msToDisplayTime(long l, boolean includeMs, boolean ignoreZero){ } return String.format(Locale.ENGLISH,"%02d:%02d:%02d", hr, min, sec); } -} \ No newline at end of file +} diff --git a/src/main/resources/apkeditor.properties b/src/main/resources/apkeditor.properties old mode 100755 new mode 100644 diff --git a/src/main/resources/color_names.txt b/src/main/resources/color_names.txt index 6d62e023..6d8716e3 100644 --- a/src/main/resources/color_names.txt +++ b/src/main/resources/color_names.txt @@ -1563,4 +1563,4 @@ #FFE4D69B,Zombie #FFA59B91,Zorba #FF044022,Zuccini -#FFEDF6FF,Zumthor \ No newline at end of file +#FFEDF6FF,Zumthor diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties new file mode 100644 index 00000000..a5186f4f --- /dev/null +++ b/src/main/resources/i18n/messages.properties @@ -0,0 +1,266 @@ +app.title=APKEDITOR - Reverse Engineering +app.brandSubtitle=Reverse Engineering +app.tagline=Decode · Build · Analyze +app.sidebarTagline=Reverse engineering at your fingertips +app.version=v1.0.0 +topbar.theme=THEME +topbar.language=LANGUAGE +window.minimize=Minimize +window.maximize=Maximize +window.close=Close +apk.placeholder=MyApplication.apk +apk.selectTooltip=Select APK (Ctrl+O) +apk.dialogTitle=Select APK +apk.filter=APK / ZIP / AAB +nav.dashboard=Dashboard +nav.decompile=Decompile +nav.analyze=Analyze +nav.smaliViewer=Smali Viewer +nav.build=Build +nav.merge=Merge +nav.refactor=Refactor +nav.protect=Protect +nav.information=Information +nav.settings=Settings +log.apk.notExists=The selected APK does not exist. +log.apk.loading=Loading APK: {0} +log.apk.loaded=APK loaded: {0} · {1} files +log.apk.analyzeError=Could not analyze APK: {0} +dashboard.title=Dashboard +dashboard.subtitle=Project control center · APKEditor core +dashboard.openWorkspace=Open workspace +dashboard.projectTitle=Project status +dashboard.currentSession=Current session +dashboard.status.ready=Ready +dashboard.status.demo=Demo +dashboard.status.loaded=Loaded +dashboard.field.apk=Current APK +dashboard.field.path=Path +dashboard.field.size=Size +dashboard.field.output=Output +dashboard.actionsTitle=Quick actions +dashboard.actionsSubtitle=Engine workflow +dashboard.action.decompile=Decompile +dashboard.action.decompileDesc=APK to smali project +dashboard.action.analyze=Analyze +dashboard.action.analyzeDesc=Manifest, permissions and components +dashboard.action.smali=Smali Viewer +dashboard.action.smaliDesc=Browse and edit code +dashboard.action.build=Build +dashboard.action.buildDesc=Project to APK +dashboard.action.merge=Merge +dashboard.action.mergeDesc=Combine APK modules +dashboard.action.refactor=Refactor +dashboard.action.refactorDesc=Rename resources +dashboard.action.protect=Protect +dashboard.action.protectDesc=Obfuscate the final package +dashboard.action.info=Information +dashboard.action.infoDesc=APK technical details +dashboard.demoApk=app-debug.apk +dashboard.demoWorkspace=demo workspace +dashboard.version=APKEDITOR v1.0.0 +decompile.title=Decompilation and Analysis +decompile.searchTooltip=Search files (Ctrl+F) +decompile.resetTooltip=Reset workspace +decompile.run=Decompile +decompile.runTooltip=Start decompilation (F5) +decompile.demoSubtitle=app-debug.apk · demo workspace +decompile.resourcesNotFound=No resources were found in the current tree. +decompile.binaryPlaceholder=; Binary file / no text content available. +decompile.busy=An operation is already running. +decompile.started=Decompilation started: {0} +decompile.progress=Extracting resources, generating smali code and analyzing the application... +decompile.outputReady=Project generated at {0} +decompile.success=Project ready: {0} +decompile.failed=Decompilation did not complete. Check the log panel. +decompile.wait=Wait for the current operation to finish. +decompile.resetDone=Workspace reset. +decompile.openFailed=Could not open: {0} +decompile.readError=Error reading {0}: {1} +tab.structure=Structure +tab.code=Code +tab.manifest=Manifest +tab.resources=Resources +tab.analysis=Analysis +report.title=APKEDITOR :: ANALYSIS REPORT +report.application=Application +report.package=Package +report.version=Version +report.versionFormat={0} (code {1}) +report.minSdk=Min SDK +report.targetSdk=Target SDK +report.apkSize=APK size +report.files=Files +report.smaliFiles=Smali files +report.classes=Classes +report.resources=Resources +report.permissions=Permissions +report.components=Components +report.engineStatus=Engine status +report.environment=Environment +report.ready=ready +report.engine=Engine +report.interface=Interface +report.editor=Editor +report.interfaceValue=Futuristic neon UI +report.editorValue=Smali syntax highlighting +op.run=Run +op.formTitle=Operation configuration +op.formSubtitle=APKEditor core · {0} +op.inputDirectory=Input directory +op.inputApk=Input APK +op.output=Output +op.hint=Output is generated automatically when the field is left empty.
Existing engine options are used without duplicating logic. +op.reset=Reset +op.browse=Browse +op.tooltipDirectory=Directory path +op.tooltipFile=File path +op.alreadyRunning=The operation is already running. +op.selectInput=Select an input before running. +op.executing=Running {0}... +op.requested=Operation requested: {0} +op.completed=Operation completed successfully. +op.failed=Operation finished with errors. Check the log. +op.stepPreparing=Preparing +op.stepRunning=Running +op.stepFinishing=Finishing +op.title.build=Build APK +op.desc.build=Compiles a decompiled project back into a signed APK. +op.title.merge=Merge APKs +op.desc.merge=Combines modules and resources into a single package. +op.title.refactor=Refactor resources +op.desc.refactor=Renames and normalizes resources, classes and paths. +op.title.protect=Protect APK +op.desc.protect=Obfuscates names and protects the resulting APK. +op.title.info=APK information +op.desc.info=Extracts technical information from the manifest and resources. +op.desc.default=APKEditor engine operation. +op.error.notRegistered=Operation not registered: {0} +op.error.unsupportedAdapter=Unsupported operation adapter: {0} +op.error.unknown=Unknown error +op.completedWithTime=Operation completed: {0} ({1} ms) +op.failedLog=Operation failed: {0} +op.aborted=Operation aborted: {0} +info.title=Information +info.subtitle=Technical details of the package and its components +info.openWorkspace=View workspace +info.detailTitle=Technical details +info.detailSubtitle=Package structure +info.field.file=File +info.field.path=Path +info.field.size=APK size +info.field.files=Files +info.field.versionCode=Version code +info.field.classes=Classes +info.field.permissions=Permissions +info.demoFile=app-debug.apk (demo) +applicationInfo.title=Application information +applicationInfo.subtitle=Manifest +applicationInfo.name=Name +applicationInfo.version=Version +applicationInfo.package=Package +applicationInfo.minSdk=Min SDK +applicationInfo.targetSdk=Target SDK +statistics.title=Statistics +statistics.subtitle=Analysis +statistics.smaliFiles=Smali files +statistics.classes=Classes +statistics.resources=Resources +statistics.permissions=Permissions +statistics.components=Components +progress.title=Decompiling and Analyzing APK +progress.subtitle=Engine pipeline +progress.description=Extracting resources, generating smali code and analyzing the application... +progress.state.running=IN PROGRESS +progress.state.completed=COMPLETED +progress.state.failed=FAILED +progress.step.loading=Loading APK +progress.step.decompiling=Decompilation +progress.step.analysis=Analysis +progress.step.report=Generating report +status.ready=System ready +status.processing=Processing... +status.memory=Memory: {0} +status.footer=ANALYZE // DECOMPILE // EXPLORE // CREATE +log.panelTitle=Log Panel +log.panelSubtitle=Real-time engine events +log.clear=Clear +log.export=Export +log.exported=Log exported: {0} +log.exportError=Could not export the log: {0} +logs.title=OPERATION LOG +logs.clear=CLEAR +editor.empty=Open a file from the explorer +editor.searchPlaceholder=Search in file... +editor.searchTooltip=Search in file (Ctrl+F) +editor.copyTooltip=Copy selection +editor.optionsTooltip=Editor options +editor.caret=Ln {0}, Col {1} +editor.spaces=Spaces: 4 +editor.gotoLine=Go to line +editor.lineNumber=Line number: +editor.menu.goto=Go to line... +editor.menu.selectAll=Select all +editor.menu.closeTab=Close tab +editor.closeTab=Close {0} +editor.language.text=Text +explorer.title=Application tree +explorer.subtitle=APK · smali · resources +explorer.search=Search file or folder... +settings.title=Settings +settings.subtitle=Appearance, paths and engine details +settings.appearance=Appearance +settings.themeSubtitle=Theme {0} +settings.themeHint=The theme is applied to the whole interface and keeps open tabs. +settings.language=Language +settings.languageHint=The interface language changes immediately. +settings.paths=Working paths +settings.currentSession=Current session +settings.input=Input +settings.output=Output +settings.workspace=Workspace +settings.engine=Engine +settings.version=Version +settings.operations=Operations +settings.java=Java +settings.interface=Interface +settings.editor=Editor +settings.shortcuts=Keyboard shortcuts +settings.productivity=Productivity +settings.shortcut.decompile=Start decompilation +settings.shortcut.selectApk=Select APK +settings.shortcut.search=Search code or file +settings.shortcut.gotoLine=Go to line +settings.shortcut.save=Save tab (editor) +settings.shortcut.closeTab=Close tab +settings.shortcut.workspace=Back to workspace +settings.shortcut.escape=Close search +settings.demoApk=app-debug.apk (demo) +settings.demoWorkspace=Demo +settings.auto=Automatic +fileSelector.browse=BROWSE +progress.ready=READY +progress.done=DONE +progress.failed=FAILED +common.unnamed=Unnamed +apk.error.notFound=File does not exist: {0} +apk.error.treeLimit=Tree limited to 6000 entries to keep the interface responsive. +apk.error.manifest=Could not read the manifest: {0} +apk.error.classes=Could not count classes: {0} +apk.error.projectLoaded=Decompiled project loaded from {0} +apk.hexHeader=; APKEDITOR - hexadecimal view (binary) +apk.hexTruncated=... (truncated) +seed.workEnvironment=Setting up the workspace... +seed.loading=Loading APK: MyApplication.apk +seed.verifying=Verifying file structure... +seed.decompileStarted=Decompilation started... +seed.extracting=Extracting resources... +seed.generating=Generating smali code... +seed.manifest=Analyzing manifest... +seed.libraries=Processing libraries... +seed.decompileDone=Decompilation completed. +seed.analysisStarted=Starting application analysis... +seed.permissions=Analyzing permissions... +seed.components=Detecting components... +seed.vulnerabilities=Reviewing vulnerabilities... +seed.analysisDone=Analysis completed. diff --git a/src/main/resources/i18n/messages_es.properties b/src/main/resources/i18n/messages_es.properties new file mode 100644 index 00000000..5c5d405f --- /dev/null +++ b/src/main/resources/i18n/messages_es.properties @@ -0,0 +1,266 @@ +app.title=APKEDITOR - Ingeniería inversa +app.brandSubtitle=Ingeniería inversa +app.tagline=Decodifica · Compila · Analiza +app.sidebarTagline=Ingeniería inversa en tus manos +app.version=v1.0.0 +topbar.theme=TEMA +topbar.language=IDIOMA +window.minimize=Minimizar +window.maximize=Maximizar +window.close=Cerrar +apk.placeholder=MyApplication.apk +apk.selectTooltip=Seleccionar APK (Ctrl+O) +apk.dialogTitle=Seleccionar APK +apk.filter=APK / ZIP / AAB +nav.dashboard=Panel +nav.decompile=Descompilar +nav.analyze=Analizar +nav.smaliViewer=Visor Smali +nav.build=Construir +nav.merge=Fusionar +nav.refactor=Refactorizar +nav.protect=Proteger +nav.information=Información +nav.settings=Configuración +log.apk.notExists=El APK seleccionado no existe. +log.apk.loading=Cargando APK: {0} +log.apk.loaded=APK cargado: {0} · {1} archivos +log.apk.analyzeError=No se pudo analizar el APK: {0} +dashboard.title=Panel +dashboard.subtitle=Centro de control del proyecto · APKEditor core +dashboard.openWorkspace=Abrir workspace +dashboard.projectTitle=Estado del proyecto +dashboard.currentSession=Sesión actual +dashboard.status.ready=Listo +dashboard.status.demo=Demo +dashboard.status.loaded=Cargado +dashboard.field.apk=APK actual +dashboard.field.path=Ruta +dashboard.field.size=Tamaño +dashboard.field.output=Salida +dashboard.actionsTitle=Acciones rápidas +dashboard.actionsSubtitle=Flujo de trabajo del motor +dashboard.action.decompile=Descompilar +dashboard.action.decompileDesc=APK a proyecto smali +dashboard.action.analyze=Analizar +dashboard.action.analyzeDesc=Manifest, permisos y componentes +dashboard.action.smali=Visor Smali +dashboard.action.smaliDesc=Explora y edita código +dashboard.action.build=Construir +dashboard.action.buildDesc=Proyecto a APK +dashboard.action.merge=Fusionar +dashboard.action.mergeDesc=Combina módulos APK +dashboard.action.refactor=Refactorizar +dashboard.action.refactorDesc=Renombra recursos +dashboard.action.protect=Proteger +dashboard.action.protectDesc=Ofusca el paquete final +dashboard.action.info=Información +dashboard.action.infoDesc=Detalle técnico del APK +dashboard.demoApk=app-debug.apk +dashboard.demoWorkspace=espacio de trabajo demo +dashboard.version=APKEDITOR v1.0.0 +decompile.title=Descompilación y Análisis +decompile.searchTooltip=Buscar archivos (Ctrl+F) +decompile.resetTooltip=Restablecer espacio de trabajo +decompile.run=Descompilar +decompile.runTooltip=Iniciar descompilación (F5) +decompile.demoSubtitle=app-debug.apk · espacio de demostración +decompile.resourcesNotFound=No se encontraron recursos en el árbol actual. +decompile.binaryPlaceholder=; Archivo binario / sin contenido de texto disponible. +decompile.busy=Ya hay una operación en curso. +decompile.started=Descompilación iniciada: {0} +decompile.progress=Extrayendo recursos, generando código smali y analizando la aplicación... +decompile.outputReady=Proyecto generado en {0} +decompile.success=Proyecto listo: {0} +decompile.failed=La descompilación no se completó. Revisa el panel de log. +decompile.wait=Espera a que termine la operación en curso. +decompile.resetDone=Espacio de trabajo restablecido. +decompile.openFailed=No se pudo abrir: {0} +decompile.readError=Error al leer {0}: {1} +tab.structure=Estructura +tab.code=Código +tab.manifest=Manifest +tab.resources=Recursos +tab.analysis=Análisis +report.title=APKEDITOR :: REPORTE DE ANÁLISIS +report.application=Aplicación +report.package=Paquete +report.version=Versión +report.versionFormat={0} (código {1}) +report.minSdk=Min SDK +report.targetSdk=Target SDK +report.apkSize=Tamaño APK +report.files=Archivos +report.smaliFiles=Archivos Smali +report.classes=Clases +report.resources=Recursos +report.permissions=Permisos +report.components=Componentes +report.engineStatus=Estado del motor +report.environment=Entorno +report.ready=listo +report.engine=Motor +report.interface=Interfaz +report.editor=Editor +report.interfaceValue=Interfaz neón futurista +report.editorValue=Resaltado de sintaxis Smali +op.run=Ejecutar +op.formTitle=Configuración de la operación +op.formSubtitle=APKEditor core · {0} +op.inputDirectory=Directorio de entrada +op.inputApk=APK de entrada +op.output=Salida +op.hint=La salida se genera automáticamente si dejas el campo vacío.
Se utilizan las opciones existentes del motor sin duplicar lógica. +op.reset=Restablecer +op.browse=Examinar +op.tooltipDirectory=Ruta del directorio +op.tooltipFile=Ruta del archivo +op.alreadyRunning=La operación ya está en ejecución. +op.selectInput=Selecciona la entrada antes de ejecutar. +op.executing=Ejecutando {0}... +op.requested=Operación solicitada: {0} +op.completed=Operación finalizada correctamente. +op.failed=La operación terminó con errores. Revisa el log. +op.stepPreparing=Preparando +op.stepRunning=Ejecutando +op.stepFinishing=Finalizando +op.title.build=Construir APK +op.desc.build=Compila un proyecto descompilado de vuelta a un APK firmado. +op.title.merge=Fusionar APKs +op.desc.merge=Combina módulos y recursos en un único paquete. +op.title.refactor=Refactorizar recursos +op.desc.refactor=Renombra y normaliza recursos, clases y rutas. +op.title.protect=Proteger APK +op.desc.protect=Ofusca nombres y protege el APK resultante. +op.title.info=Información del APK +op.desc.info=Extrae información técnica del manifiesto y recursos. +op.desc.default=Operación del motor APKEditor. +op.error.notRegistered=Operación no registrada: {0} +op.error.unsupportedAdapter=Adaptador de operación no soportado: {0} +op.error.unknown=Error desconocido +op.completedWithTime=Operación completada: {0} ({1} ms) +op.failedLog=Operación fallida: {0} +op.aborted=Operación abortada: {0} +info.title=Información +info.subtitle=Detalle técnico del paquete y sus componentes +info.openWorkspace=Ver workspace +info.detailTitle=Detalle técnico +info.detailSubtitle=Estructura del paquete +info.field.file=Archivo +info.field.path=Ruta +info.field.size=Tamaño APK +info.field.files=Archivos +info.field.versionCode=Código de versión +info.field.classes=Clases +info.field.permissions=Permisos +info.demoFile=app-debug.apk (demo) +applicationInfo.title=Información de la aplicación +applicationInfo.subtitle=Manifest +applicationInfo.name=Nombre +applicationInfo.version=Versión +applicationInfo.package=Paquete +applicationInfo.minSdk=Min SDK +applicationInfo.targetSdk=Target SDK +statistics.title=Estadísticas +statistics.subtitle=Análisis +statistics.smaliFiles=Archivos Smali +statistics.classes=Clases +statistics.resources=Recursos +statistics.permissions=Permisos +statistics.components=Componentes +progress.title=Descompilando y Analizando APK +progress.subtitle=Pipeline del motor +progress.description=Extrayendo recursos, generando código smali y analizando la aplicación... +progress.state.running=EN PROCESO +progress.state.completed=COMPLETADO +progress.state.failed=FALLIDO +progress.step.loading=Cargando APK +progress.step.decompiling=Descompilación +progress.step.analysis=Análisis +progress.step.report=Generando reporte +status.ready=Sistema listo +status.processing=Procesando... +status.memory=Memoria: {0} +status.footer=ANALIZA // DESCOMPILA // EXPLORA // CREA +log.panelTitle=Panel de Log +log.panelSubtitle=Eventos del motor en tiempo real +log.clear=Limpiar +log.export=Exportar +log.exported=Log exportado: {0} +log.exportError=No se pudo exportar el log: {0} +logs.title=REGISTRO DE OPERACIONES +logs.clear=LIMPIAR +editor.empty=Abre un archivo desde el explorador +editor.searchPlaceholder=Buscar en el archivo... +editor.searchTooltip=Buscar en el archivo (Ctrl+F) +editor.copyTooltip=Copiar selección +editor.optionsTooltip=Opciones del editor +editor.caret=Línea {0}, Col {1} +editor.spaces=Espacios: 4 +editor.gotoLine=Ir a línea +editor.lineNumber=Número de línea: +editor.menu.goto=Ir a línea... +editor.menu.selectAll=Seleccionar todo +editor.menu.closeTab=Cerrar pestaña +editor.closeTab=Cerrar {0} +editor.language.text=Texto +explorer.title=Árbol de la aplicación +explorer.subtitle=APK · smali · recursos +explorer.search=Buscar archivo o carpeta... +settings.title=Configuración +settings.subtitle=Apariencia, rutas y detalles del motor +settings.appearance=Apariencia +settings.themeSubtitle=Tema {0} +settings.themeHint=El tema se aplica en toda la interfaz y conserva las pestañas abiertas. +settings.language=Idioma +settings.languageHint=El idioma de la interfaz cambia al instante. +settings.paths=Rutas de trabajo +settings.currentSession=Sesión actual +settings.input=Entrada +settings.output=Salida +settings.workspace=Espacio de trabajo +settings.engine=Motor +settings.version=Versión +settings.operations=Operaciones +settings.java=Java +settings.interface=Interfaz +settings.editor=Editor +settings.shortcuts=Atajos de teclado +settings.productivity=Productividad +settings.shortcut.decompile=Iniciar descompilación +settings.shortcut.selectApk=Seleccionar APK +settings.shortcut.search=Buscar código o archivo +settings.shortcut.gotoLine=Ir a línea +settings.shortcut.save=Guardar pestaña (editor) +settings.shortcut.closeTab=Cerrar pestaña +settings.shortcut.workspace=Volver al workspace +settings.shortcut.escape=Cerrar búsqueda +settings.demoApk=app-debug.apk (demo) +settings.demoWorkspace=Demo +settings.auto=Automática +fileSelector.browse=EXAMINAR +progress.ready=LISTO +progress.done=COMPLETADO +progress.failed=FALLIDO +common.unnamed=Sin nombre +apk.error.notFound=No existe el archivo: {0} +apk.error.treeLimit=Árbol limitado a 6000 entradas para mantener la interfaz fluida. +apk.error.manifest=No se pudo leer el manifest: {0} +apk.error.classes=No se pudieron contar las clases: {0} +apk.error.projectLoaded=Proyecto descompilado cargado desde {0} +apk.hexHeader=; APKEDITOR - vista hexadecimal (binario) +apk.hexTruncated=... (truncado) +seed.workEnvironment=Estableciendo entorno de trabajo... +seed.loading=Cargando APK: MyApplication.apk +seed.verifying=Verificando estructura del archivo... +seed.decompileStarted=Descompilación iniciada... +seed.extracting=Extrayendo recursos... +seed.generating=Generando código smali... +seed.manifest=Analizando manifest... +seed.libraries=Procesando librerías... +seed.decompileDone=Descompilación completada. +seed.analysisStarted=Iniciando análisis de la aplicación... +seed.permissions=Analizando permisos... +seed.components=Detectando componentes... +seed.vulnerabilities=Revisando vulnerabilidades... +seed.analysisDone=Análisis completado. diff --git a/src/main/resources/protect_dic_dir_name.txt b/src/main/resources/protect_dic_dir_name.txt index 6606bb20..e3c6972b 100644 --- a/src/main/resources/protect_dic_dir_name.txt +++ b/src/main/resources/protect_dic_dir_name.txt @@ -24,4 +24,4 @@ res/values/integers.xml res/values/plurals.xml res/values/public.xml res/values/strings.xml -res/values/styles.xml" \ No newline at end of file +res/values/styles.xml" diff --git a/src/main/resources/protect_dic_file_name.txt b/src/main/resources/protect_dic_file_name.txt index 415e0c73..bd99cf95 100644 --- a/src/main/resources/protect_dic_file_name.txt +++ b/src/main/resources/protect_dic_file_name.txt @@ -10,4 +10,4 @@ classes.dex AndroidManifest.xml AndroidManifest -resources.arsc \ No newline at end of file +resources.arsc