diff --git a/README.md b/README.md index 99b8aa46a..3170e13fa 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ options: -j, --threads-count - processing threads count -r, --no-res - do not decode resources -s, --no-src - do not decompile source code + -e, --export-gradle - save as android gradle project --show-bad-code - show inconsistent code (incorrectly decompiled) --no-replace-consts - don't replace constant value with matching constant field --escape-unicode - escape non latin characters in strings (with \u) diff --git a/jadx-cli/src/main/java/jadx/cli/JadxCLIArgs.java b/jadx-cli/src/main/java/jadx/cli/JadxCLIArgs.java index 632adb33a..8d70658fe 100644 --- a/jadx-cli/src/main/java/jadx/cli/JadxCLIArgs.java +++ b/jadx-cli/src/main/java/jadx/cli/JadxCLIArgs.java @@ -40,6 +40,9 @@ public class JadxCLIArgs implements IJadxArgs { @Parameter(names = {"-s", "--no-src"}, description = "do not decompile source code") protected boolean skipSources = false; + @Parameter(names = {"-e", "--export-gradle"}, description = "save as android gradle project") + protected boolean exportAsGradleProject = false; + @Parameter(names = {"--show-bad-code"}, description = "show inconsistent code (incorrectly decompiled)") protected boolean showInconsistentCode = false; @@ -281,4 +284,9 @@ public class JadxCLIArgs implements IJadxArgs { public boolean isReplaceConsts() { return replaceConsts; } + + @Override + public boolean isExportAsGradleProject() { + return exportAsGradleProject; + } } diff --git a/jadx-core/src/main/java/jadx/api/IJadxArgs.java b/jadx-core/src/main/java/jadx/api/IJadxArgs.java index 212ae6199..7b7111f56 100644 --- a/jadx-core/src/main/java/jadx/api/IJadxArgs.java +++ b/jadx-core/src/main/java/jadx/api/IJadxArgs.java @@ -37,4 +37,9 @@ public interface IJadxArgs { * Replace constant values with static final fields with same value */ boolean isReplaceConsts(); + + /** + * Save as gradle project + */ + boolean isExportAsGradleProject(); } diff --git a/jadx-core/src/main/java/jadx/api/JadxArgs.java b/jadx-core/src/main/java/jadx/api/JadxArgs.java index 81c3746c4..804b3bf49 100644 --- a/jadx-core/src/main/java/jadx/api/JadxArgs.java +++ b/jadx-core/src/main/java/jadx/api/JadxArgs.java @@ -26,6 +26,7 @@ public class JadxArgs implements IJadxArgs { private boolean escapeUnicode = false; private boolean replaceConsts = true; + private boolean exportAsGradleProject = false; @Override public File getOutDir() { @@ -170,4 +171,13 @@ public class JadxArgs implements IJadxArgs { public void setReplaceConsts(boolean replaceConsts) { this.replaceConsts = replaceConsts; } + + @Override + public boolean isExportAsGradleProject() { + return exportAsGradleProject; + } + + public void setExportAsGradleProject(boolean exportAsGradleProject) { + this.exportAsGradleProject = exportAsGradleProject; + } } diff --git a/jadx-core/src/main/java/jadx/api/JadxDecompiler.java b/jadx-core/src/main/java/jadx/api/JadxDecompiler.java index cbab76925..5382b02e0 100644 --- a/jadx-core/src/main/java/jadx/api/JadxDecompiler.java +++ b/jadx-core/src/main/java/jadx/api/JadxDecompiler.java @@ -3,12 +3,14 @@ package jadx.api; import jadx.core.Jadx; import jadx.core.ProcessClass; import jadx.core.codegen.CodeGen; +import jadx.core.dex.attributes.AFlag; import jadx.core.dex.nodes.ClassNode; import jadx.core.dex.nodes.FieldNode; import jadx.core.dex.nodes.MethodNode; import jadx.core.dex.nodes.RootNode; import jadx.core.dex.visitors.IDexTreeVisitor; import jadx.core.dex.visitors.SaveCode; +import jadx.core.export.ExportGradleProject; import jadx.core.utils.exceptions.DecodeException; import jadx.core.utils.exceptions.JadxException; import jadx.core.utils.exceptions.JadxRuntimeException; @@ -150,7 +152,7 @@ public final class JadxDecompiler { return getSaveExecutor(!args.isSkipSources(), !args.isSkipResources()); } - private ExecutorService getSaveExecutor(boolean saveSources, final boolean saveResources) { + private ExecutorService getSaveExecutor(boolean saveSources, boolean saveResources) { if (root == null) { throw new JadxRuntimeException("No loaded files"); } @@ -159,25 +161,48 @@ public final class JadxDecompiler { LOG.info("processing ..."); ExecutorService executor = Executors.newFixedThreadPool(threadsCount); + + File sourcesOutDir; + File resOutDir; + if (args.isExportAsGradleProject()) { + ExportGradleProject export = new ExportGradleProject(root, outDir); + export.init(); + sourcesOutDir = export.getSrcOutDir(); + resOutDir = export.getResOutDir(); + } else { + sourcesOutDir = outDir; + resOutDir = outDir; + } if (saveSources) { - for (final JavaClass cls : getClasses()) { - executor.execute(new Runnable() { - @Override - public void run() { - cls.decompile(); - SaveCode.save(outDir, args, cls.getClassNode()); - } - }); - } + appendSourcesSave(executor, sourcesOutDir); } if (saveResources) { - for (final ResourceFile resourceFile : getResources()) { - executor.execute(new ResourcesSaver(outDir, resourceFile)); - } + appendResourcesSave(executor, resOutDir); } return executor; } + private void appendResourcesSave(ExecutorService executor, File outDir) { + for (ResourceFile resourceFile : getResources()) { + executor.execute(new ResourcesSaver(outDir, resourceFile)); + } + } + + private void appendSourcesSave(ExecutorService executor, final File outDir) { + for (final JavaClass cls : getClasses()) { + if (cls.getClassNode().contains(AFlag.DONT_GENERATE)) { + continue; + } + executor.execute(new Runnable() { + @Override + public void run() { + cls.decompile(); + SaveCode.save(outDir, args, cls.getClassNode()); + } + }); + } + } + public List getClasses() { if (root == null) { return Collections.emptyList(); diff --git a/jadx-core/src/main/java/jadx/core/export/ExportGradleProject.java b/jadx-core/src/main/java/jadx/core/export/ExportGradleProject.java new file mode 100644 index 000000000..9f912f681 --- /dev/null +++ b/jadx-core/src/main/java/jadx/core/export/ExportGradleProject.java @@ -0,0 +1,85 @@ +package jadx.core.export; + +import jadx.core.dex.attributes.AFlag; +import jadx.core.dex.nodes.ClassNode; +import jadx.core.dex.nodes.DexNode; +import jadx.core.dex.nodes.RootNode; +import jadx.core.utils.exceptions.JadxRuntimeException; +import jadx.core.utils.files.FileUtils; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ExportGradleProject { + + private static final Logger LOG = LoggerFactory.getLogger(ExportGradleProject.class); + + private static final Set IGNORE_CLS_NAMES = new HashSet(Arrays.asList( + "R", + "BuildConfig" + )); + + private final RootNode root; + private final File outDir; + private File srcOutDir; + private File resOutDir; + + public ExportGradleProject(RootNode root, File outDir) { + this.root = root; + this.outDir = outDir; + this.srcOutDir = new File(outDir, "src/main/java"); + this.resOutDir = new File(outDir, "src/main"); + } + + public void init() { + try { + FileUtils.makeDirsForFile(srcOutDir); + FileUtils.makeDirsForFile(resOutDir); + saveBuildGradle(); + skipGeneratedClasses(); + } catch (Exception e) { + throw new JadxRuntimeException("Gradle export failed", e); + } + } + + private void saveBuildGradle() throws IOException { + TemplateFile tmpl = TemplateFile.fromResources("/export/build.gradle.tmpl"); + String appPackage = root.getAppPackage(); + if (appPackage == null) { + appPackage = "UNKNOWN"; + } + tmpl.add("applicationId", appPackage); + // TODO: load from AndroidManifest.xml + tmpl.add("minSdkVersion", 9); + tmpl.add("targetSdkVersion", 21); + tmpl.save(new File(outDir, "build.gradle")); + } + + private void skipGeneratedClasses() { + for (DexNode dexNode : root.getDexNodes()) { + List classes = dexNode.getClasses(); + for (ClassNode cls : classes) { + String shortName = cls.getClassInfo().getShortName(); + if (IGNORE_CLS_NAMES.contains(shortName)) { + cls.add(AFlag.DONT_GENERATE); + LOG.debug("Skip class: {}", cls); + } + } + } + } + + public File getSrcOutDir() { + return srcOutDir; + } + + public File getResOutDir() { + return resOutDir; + } +} diff --git a/jadx-core/src/main/java/jadx/core/export/TemplateFile.java b/jadx-core/src/main/java/jadx/core/export/TemplateFile.java new file mode 100644 index 000000000..ce476142e --- /dev/null +++ b/jadx-core/src/main/java/jadx/core/export/TemplateFile.java @@ -0,0 +1,162 @@ +package jadx.core.export; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static jadx.core.utils.files.FileUtils.close; + +/** + * Simple template engine + * Syntax for replace variable with value: '{{variable}}' + */ +public class TemplateFile { + + private enum State { + NONE, START, VARIABLE, END + } + + private static class ParserState { + private State state = State.NONE; + private StringBuilder curVariable; + private boolean skip; + } + + private final String templateName; + private final InputStream template; + private final Map values = new HashMap(); + + public static TemplateFile fromResources(String path) throws FileNotFoundException { + InputStream res = TemplateFile.class.getResourceAsStream(path); + if (res == null) { + throw new FileNotFoundException("Resource not found: " + path); + } + return new TemplateFile(path, res); + } + + private TemplateFile(String name, InputStream in) throws FileNotFoundException { + this.templateName = name; + this.template = in; + } + + public void add(String name, @NotNull Object value) { + values.put(name, value.toString()); + } + + public String build() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + process(out); + } finally { + close(out); + } + return out.toString(); + } + + public void save(File outFile) throws IOException { + OutputStream out = new FileOutputStream(outFile); + try { + process(out); + } finally { + close(out); + } + } + + private void process(OutputStream out) throws IOException { + if (template.available() == 0) { + throw new IOException("Template already processed"); + } + InputStream in = null; + try { + in = new BufferedInputStream(template); + ParserState state = new ParserState(); + while (true) { + int ch = in.read(); + if (ch == -1) { + break; + } + String str = process(state, (char) ch); + if (str != null) { + out.write(str.getBytes()); + } else if (!state.skip) { + out.write(ch); + } + } + } finally { + close(in); + } + } + + @Nullable + private String process(ParserState parser, char ch) { + State state = parser.state; + switch (ch) { + case '{': + switch (state) { + case START: + parser.state = State.VARIABLE; + parser.curVariable = new StringBuilder(); + break; + + default: + parser.state = State.START; + break; + } + parser.skip = true; + return null; + + case '}': + switch (state) { + case VARIABLE: + parser.state = State.END; + parser.skip = true; + return null; + + case END: + parser.state = State.NONE; + String varName = parser.curVariable.toString(); + parser.curVariable = new StringBuilder(); + return processVar(varName); + } + break; + + default: + switch (state) { + case VARIABLE: + parser.curVariable.append(ch); + parser.skip = true; + return null; + + case START: + parser.state = State.NONE; + return "{" + ch; + + case END: + throw new RuntimeException("Expected variable end: '" + parser.curVariable + + "' (missing second '}')"); + } + break; + } + parser.skip = false; + return null; + } + + private String processVar(String varName) { + String str = values.get(varName); + if (str == null) { + throw new RuntimeException("Unknown variable: '" + varName + + "' in template: " + templateName); + } + return str; + } +} diff --git a/jadx-core/src/main/resources/export/build.gradle.tmpl b/jadx-core/src/main/resources/export/build.gradle.tmpl new file mode 100644 index 000000000..483ab5804 --- /dev/null +++ b/jadx-core/src/main/resources/export/build.gradle.tmpl @@ -0,0 +1,35 @@ +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:1.5.0' + } +} +apply plugin: 'com.android.application' + +repositories { + mavenCentral() + jcenter() +} + +android { + compileSdkVersion 23 + buildToolsVersion '23.0.1' + + defaultConfig { + applicationId '{{applicationId}}' + minSdkVersion {{minSdkVersion}} + targetSdkVersion {{targetSdkVersion}} + versionCode 1 + versionName "1.0" + } + + lintOptions { + abortOnError false + } +} + +dependencies { + // some dependencies +} diff --git a/jadx-core/src/test/java/jadx/tests/functional/TemplateFileTest.java b/jadx-core/src/test/java/jadx/tests/functional/TemplateFileTest.java new file mode 100644 index 000000000..eb980c1cd --- /dev/null +++ b/jadx-core/src/test/java/jadx/tests/functional/TemplateFileTest.java @@ -0,0 +1,24 @@ +package jadx.tests.functional; + +import jadx.core.export.TemplateFile; + +import org.junit.Test; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertThat; + +public class TemplateFileTest { + + @Test + public void testBuildGradle() throws Exception { + TemplateFile tmpl = TemplateFile.fromResources("/export/build.gradle.tmpl"); + tmpl.add("applicationId", "SOME_ID"); + tmpl.add("minSdkVersion", 1); + tmpl.add("targetSdkVersion", 2); + String res = tmpl.build(); + System.out.println(res); + + assertThat(res, containsString("applicationId 'SOME_ID'")); + assertThat(res, containsString("targetSdkVersion 2")); + } +} diff --git a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java index 02c7e90de..13c864e2f 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/JadxSettings.java @@ -180,6 +180,10 @@ public class JadxSettings extends JadxCLIArgs { this.autoStartJobs = autoStartJobs; } + public void setExportAsGradleProject(boolean exportAsGradleProject) { + this.exportAsGradleProject = exportAsGradleProject; + } + public Font getFont() { if (fontStr.isEmpty()) { return DEFAULT_FONT; diff --git a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java index c13644d84..e4a6f8912 100644 --- a/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java +++ b/jadx-gui/src/main/java/jadx/gui/ui/MainWindow.java @@ -89,6 +89,7 @@ public class MainWindow extends JFrame { private static final ImageIcon ICON_OPEN = Utils.openIcon("folder"); private static final ImageIcon ICON_SAVE_ALL = Utils.openIcon("disk_multiple"); + private static final ImageIcon ICON_EXPORT = Utils.openIcon("database_save"); private static final ImageIcon ICON_CLOSE = Utils.openIcon("cross"); private static final ImageIcon ICON_SYNC = Utils.openIcon("sync"); private static final ImageIcon ICON_FLAT_PKG = Utils.openIcon("empty_logical_package_obj"); @@ -232,7 +233,13 @@ public class MainWindow extends JFrame { } } - private void saveAll() { + private void saveAll(boolean export) { + settings.setExportAsGradleProject(export); + if (export) { + settings.setSkipSources(false); + settings.setSkipResources(false); + } + JFileChooser fileChooser = new JFileChooser(); fileChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); fileChooser.setToolTipText(NLS.str("file.save_all_msg")); @@ -351,12 +358,21 @@ public class MainWindow extends JFrame { Action saveAllAction = new AbstractAction(NLS.str("file.save_all"), ICON_SAVE_ALL) { @Override public void actionPerformed(ActionEvent e) { - saveAll(); + saveAll(false); } }; saveAllAction.putValue(Action.SHORT_DESCRIPTION, NLS.str("file.save_all")); saveAllAction.putValue(Action.ACCELERATOR_KEY, getKeyStroke(KeyEvent.VK_S, KeyEvent.CTRL_DOWN_MASK)); + Action exportAction = new AbstractAction(NLS.str("file.export_gradle"), ICON_EXPORT) { + @Override + public void actionPerformed(ActionEvent e) { + saveAll(true); + } + }; + exportAction.putValue(Action.SHORT_DESCRIPTION, NLS.str("file.export_gradle")); + exportAction.putValue(Action.ACCELERATOR_KEY, getKeyStroke(KeyEvent.VK_E, KeyEvent.CTRL_DOWN_MASK)); + JMenu recentFiles = new JMenu(NLS.str("menu.recent_files")); recentFiles.addMenuListener(new RecentFilesMenuListener(recentFiles)); @@ -467,6 +483,7 @@ public class MainWindow extends JFrame { file.setMnemonic(KeyEvent.VK_F); file.add(openAction); file.add(saveAllAction); + file.add(exportAction); file.addSeparator(); file.add(recentFiles); file.addSeparator(); @@ -523,6 +540,7 @@ public class MainWindow extends JFrame { toolbar.setFloatable(false); toolbar.add(openAction); toolbar.add(saveAllAction); + toolbar.add(exportAction); toolbar.addSeparator(); toolbar.add(syncAction); toolbar.add(flatPkgButton); diff --git a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties index 7401a46d1..ac2387c0c 100644 --- a/jadx-gui/src/main/resources/i18n/Messages_en_US.properties +++ b/jadx-gui/src/main/resources/i18n/Messages_en_US.properties @@ -16,6 +16,7 @@ menu.update_label=New version %s available! file.open=Open file file.save_all=Save all +file.export_gradle=Save as gradle project file.save_all_msg=Select directory for save decompiled sources file.select=Select file.exit=Exit diff --git a/jadx-gui/src/main/resources/icons-16/database_save.png b/jadx-gui/src/main/resources/icons-16/database_save.png new file mode 100644 index 000000000..44c06dddf Binary files /dev/null and b/jadx-gui/src/main/resources/icons-16/database_save.png differ diff --git a/jadx-test-app/build.gradle b/jadx-test-app/build.gradle index 3d161df3b..8ddbc1eed 100644 --- a/jadx-test-app/build.gradle +++ b/jadx-test-app/build.gradle @@ -1,11 +1,10 @@ -project.ext { +ext { testAppDir = 'test-app' testAppTmpDir = 'test-app-tmp' - buildFile = "${testAppTmpDir}/build.gradle" + tmpBuildFile = "${testAppTmpDir}/build.gradle" apkFile = "${testAppTmpDir}/build/outputs/apk/test-app-tmp-debug.apk" - outSrcDir = "${testAppTmpDir}/src/main/java" - outResDir = "${testAppTmpDir}/src/main" + outCodeDir = "${testAppTmpDir}/src/main" checkTask = 'connectedCheck' } @@ -32,24 +31,30 @@ task buildApp(type:Exec, dependsOn: copyApp) { } task removeSource(type:Delete, dependsOn: buildApp) { - delete "${outResDir}/**" + delete outCodeDir } -task runJadxSrc(type: JavaExec, dependsOn: removeSource) { +task runJadx(type: JavaExec, dependsOn: removeSource) { classpath = sourceSets.main.output + configurations.compile main = project(':jadx-cli').mainClassName - args = ['-d', outSrcDir, '-r', apkFile, '-v'] + args = ['-d', testAppTmpDir, apkFile, '-v', '-e'] } -task runJadxResources(type: JavaExec, dependsOn: runJadxSrc) { - classpath = sourceSets.main.output + configurations.compile - main = project(':jadx-cli').mainClassName - args = ['-d', outResDir, '-s', apkFile, '-v'] +task decompile(dependsOn: runJadx) { + doLast { + injectDependencies() + } } -task decompile(type:Delete, dependsOn: runJadxResources) { - delete "${outSrcDir}/com/github/skylot/jadx/testapp/BuildConfig.java" - delete "${outSrcDir}/com/github/skylot/jadx/testapp/R.java" +def injectDependencies() { + def fileContent = file(tmpBuildFile).getText('UTF-8') + def updatedContent = fileContent.replaceAll( + '// some dependencies', + """ + androidTestCompile 'junit:junit:4.12' + androidTestCompile 'org.hamcrest:hamcrest-library:1.3' + """) + file(tmpBuildFile).write(updatedContent, 'UTF-8') } task runChecks(type:Exec, dependsOn: decompile) {