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
+
+
+
+> [!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
-```
-
-
- 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
+
+
+
+> [!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("?[\\w:.-]+");
+ private static final Pattern XML_ATTRIBUTE = Pattern.compile("\\b[\\w:.-]+(?=\\s*=)");
+ private static final Pattern JSON_KEY = Pattern.compile("\"[^\"]*\"(?=\\s*:)");
+ private static final Pattern HEX_COMMENT = Pattern.compile("^\\s*;.*$");
+
+ private static final Set 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 extends ZipEntry> 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 extends ZipEntry> 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