feat(cli): install and manage plugins from command line

This commit is contained in:
Skylot 2023-05-23 17:53:48 +01:00
parent 67054bda3d
commit 8a67c39279
No known key found for this signature in database
GPG Key ID: 1E23F5B52567AA39
32 changed files with 1162 additions and 25 deletions

View File

@ -79,7 +79,12 @@ and also packed to `build/jadx-<version>.zip`
### Usage
```
jadx[-gui] [options] <input files> (.apk, .dex, .jar, .class, .smali, .zip, .aar, .arsc, .aab)
jadx[-gui] [command] [options] <input files> (.apk, .dex, .jar, .class, .smali, .zip, .aar, .arsc, .aab)
commands (use '<command> --help' for command options):
plugins - manage jadx plugins
options:
-d, --output-dir - output directory
options:
-d, --output-dir - output directory
-ds, --output-dir-src - output directory for sources

View File

@ -7,6 +7,7 @@ plugins {
dependencies {
implementation(project(':jadx-core'))
implementation(project(':jadx-plugins-tools'))
runtimeOnly(project(':jadx-plugins:jadx-dex-input'))
runtimeOnly(project(':jadx-plugins:jadx-java-input'))

View File

@ -25,13 +25,17 @@ import jadx.api.plugins.options.OptionDescription;
import jadx.core.plugins.JadxPluginManager;
import jadx.core.plugins.PluginContext;
import jadx.core.utils.Utils;
import jadx.plugins.tools.JadxExternalPluginsLoader;
public class JCommanderWrapper<T> {
private final JCommander jc;
private final JadxCLIArgs argsObj;
public JCommanderWrapper(JadxCLIArgs argsObj) {
this.jc = JCommander.newBuilder().addObject(argsObj).build();
JCommander.Builder builder = JCommander.newBuilder().addObject(argsObj);
builder.acceptUnknownOptions(true); // workaround for "default" command
JadxCLICommands.append(builder);
this.jc = builder.build();
this.argsObj = argsObj;
}
@ -46,6 +50,14 @@ public class JCommanderWrapper<T> {
}
}
public boolean processCommands() {
String parsedCommand = jc.getParsedCommand();
if (parsedCommand == null) {
return false;
}
return JadxCLICommands.process(this, jc, parsedCommand);
}
public void overrideProvided(JadxCLIArgs obj) {
List<ParameterDescription> fieldsParams = jc.getParameters();
List<ParameterDescription> parameters = new ArrayList<>(1 + fieldsParams.size());
@ -73,6 +85,10 @@ public class JCommanderWrapper<T> {
return value;
}
public List<String> getUnknownOptions() {
return jc.getUnknownOptions();
}
public void printUsage() {
LogHelper.setLogLevel(LogHelper.LogLevelEnum.ERROR); // mute logger while printing help
@ -81,7 +97,32 @@ public class JCommanderWrapper<T> {
out.println();
out.println("jadx - dex to java decompiler, version: " + JadxDecompiler.getVersion());
out.println();
out.println("usage: jadx [options] " + jc.getMainParameterDescription());
out.println("usage: jadx [command] [options] " + jc.getMainParameterDescription());
out.println("commands (use '<command> --help' for command options):");
for (String command : jc.getCommands().keySet()) {
out.println(" " + command + "\t - " + jc.getUsageFormatter().getCommandDescription(command));
}
out.println();
int maxNamesLen = printOptions(jc, out, true);
out.println(appendPluginOptions(maxNamesLen));
out.println();
out.println("Examples:");
out.println(" jadx -d out classes.dex");
out.println(" jadx --rename-flags \"none\" classes.dex");
out.println(" jadx --rename-flags \"valid, printable\" classes.dex");
out.println(" jadx --log-level ERROR app.apk");
out.println(" jadx -Pdex-input.verify-checksum=no app.apk");
}
public void printUsage(JCommander subCommander) {
PrintStream out = System.out;
out.println("usage: " + subCommander.getProgramName() + " [options]");
printOptions(subCommander, out, false);
}
private static int printOptions(JCommander jc, PrintStream out, boolean addDefaults) {
out.println("options:");
List<ParameterDescription> params = jc.getParameters();
@ -96,7 +137,7 @@ public class JCommanderWrapper<T> {
}
maxNamesLen += 3;
JadxCLIArgs args = (JadxCLIArgs) jc.getObjects().get(0);
Object args = jc.getObjects().get(0);
for (Field f : getFields(args.getClass())) {
String name = f.getName();
ParameterDescription p = paramsMap.get(name);
@ -118,26 +159,21 @@ public class JCommanderWrapper<T> {
} else {
opt.append("- ").append(description);
}
String defaultValue = getDefaultValue(args, f, opt);
if (defaultValue != null && !description.contains("(default)")) {
opt.append(", default: ").append(defaultValue);
if (addDefaults) {
String defaultValue = getDefaultValue(args, f, opt);
if (defaultValue != null && !description.contains("(default)")) {
opt.append(", default: ").append(defaultValue);
}
}
out.println(opt);
}
out.println(appendPluginOptions(maxNamesLen));
out.println();
out.println("Examples:");
out.println(" jadx -d out classes.dex");
out.println(" jadx --rename-flags \"none\" classes.dex");
out.println(" jadx --rename-flags \"valid, printable\" classes.dex");
out.println(" jadx --log-level ERROR app.apk");
out.println(" jadx -Pdex-input.verify-checksum=no app.apk");
return maxNamesLen;
}
/**
* Get all declared fields of the specified class and all super classes
*/
private List<Field> getFields(Class<?> clazz) {
private static List<Field> getFields(Class<?> clazz) {
List<Field> fieldList = new ArrayList<>();
while (clazz != null) {
fieldList.addAll(Arrays.asList(clazz.getDeclaredFields()));
@ -147,7 +183,7 @@ public class JCommanderWrapper<T> {
}
@Nullable
private String getDefaultValue(JadxCLIArgs args, Field f, StringBuilder opt) {
private static String getDefaultValue(Object args, Field f, StringBuilder opt) {
try {
Class<?> fieldType = f.getType();
if (fieldType == int.class) {
@ -180,7 +216,7 @@ public class JCommanderWrapper<T> {
// load and init all options plugins to print all options
try (JadxDecompiler decompiler = new JadxDecompiler(new JadxArgs())) {
JadxPluginManager pluginManager = decompiler.getPluginManager();
pluginManager.load();
pluginManager.load(new JadxExternalPluginsLoader());
pluginManager.initAll();
for (PluginContext context : pluginManager.getAllPluginContexts()) {
JadxPluginOptions options = context.getOptions();

View File

@ -10,6 +10,7 @@ import jadx.api.impl.SimpleCodeWriter;
import jadx.cli.LogHelper.LogLevelEnum;
import jadx.core.utils.exceptions.JadxArgsValidateException;
import jadx.core.utils.files.FileUtils;
import jadx.plugins.tools.JadxExternalPluginsLoader;
public class JadxCLI {
private static final Logger LOG = LoggerFactory.getLogger(JadxCLI.class);
@ -44,6 +45,7 @@ public class JadxCLI {
JadxArgs jadxArgs = cliArgs.toJadxArgs();
jadxArgs.setCodeCache(new NoOpCodeCache());
jadxArgs.setCodeWriterProvider(SimpleCodeWriter::new);
jadxArgs.setPluginLoader(new JadxExternalPluginsLoader());
try (JadxDecompiler jadx = new JadxDecompiler(jadxArgs)) {
jadx.load();
if (checkForErrors(jadx)) {

View File

@ -264,6 +264,10 @@ public class JadxCLIArgs {
}
private boolean process(JCommanderWrapper<JadxCLIArgs> jcw) {
files.addAll(jcw.getUnknownOptions());
if (jcw.processCommands()) {
return false;
}
if (printHelp) {
jcw.printUsage();
return false;

View File

@ -0,0 +1,35 @@
package jadx.cli;
import java.util.Map;
import java.util.TreeMap;
import com.beust.jcommander.JCommander;
import jadx.cli.commands.CommandPlugins;
import jadx.cli.commands.ICommand;
public class JadxCLICommands {
private static final Map<String, ICommand> COMMANDS_MAP = new TreeMap<>();
static {
JadxCLICommands.register(new CommandPlugins());
}
public static void register(ICommand command) {
COMMANDS_MAP.put(command.name(), command);
}
public static void append(JCommander.Builder builder) {
COMMANDS_MAP.forEach(builder::addCommand);
}
public static boolean process(JCommanderWrapper<?> jcw, JCommander jc, String parsedCommand) {
ICommand command = COMMANDS_MAP.get(parsedCommand);
if (command == null) {
throw new IllegalArgumentException("Unknown command: " + parsedCommand);
}
JCommander subCommander = jc.getCommands().get(parsedCommand);
command.process(jcw, subCommander);
return true;
}
}

View File

@ -15,6 +15,7 @@ import jadx.api.JadxArgs;
import jadx.api.JadxDecompiler;
import jadx.api.plugins.input.ICodeLoader;
import jadx.api.plugins.input.JadxCodeInput;
import jadx.api.plugins.loader.JadxBasePluginLoader;
import jadx.core.clsp.ClsSet;
import jadx.core.dex.nodes.ClassNode;
import jadx.core.dex.nodes.RootNode;
@ -43,7 +44,7 @@ public class ConvertToClsSet {
jadxArgs.setRenameFlags(EnumSet.noneOf(JadxArgs.RenameEnum.class));
try (JadxDecompiler decompiler = new JadxDecompiler(jadxArgs)) {
JadxPluginManager pluginManager = decompiler.getPluginManager();
pluginManager.load();
pluginManager.load(new JadxBasePluginLoader());
pluginManager.initResolved();
List<ICodeLoader> loadedInputs = new ArrayList<>();
for (JadxCodeInput inputPlugin : pluginManager.getCodeInputs()) {

View File

@ -0,0 +1,81 @@
package jadx.cli.commands;
import java.util.List;
import com.beust.jcommander.JCommander;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.Parameters;
import jadx.cli.JCommanderWrapper;
import jadx.plugins.tools.JadxPluginsTools;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.data.JadxPluginUpdate;
@Parameters(commandDescription = "manage jadx plugins")
public class CommandPlugins implements ICommand {
@Parameter(names = { "-i", "--install" }, description = "install plugin with locationId")
protected String install;
@Parameter(names = { "-j", "--install-jar" }, description = "install plugin from jar file")
protected String installJar;
@Parameter(names = { "-l", "--list" }, description = "list installed plugins")
protected boolean list;
@Parameter(names = { "-u", "--update" }, description = "update installed plugins")
protected boolean update;
@Parameter(names = { "--uninstall" }, description = "uninstall plugin with pluginId")
protected String uninstall;
@Parameter(names = { "-h", "--help" }, description = "print this help", help = true)
protected boolean printHelp = false;
@Override
public String name() {
return "plugins";
}
@Override
public void process(JCommanderWrapper<?> jcw, JCommander subCommander) {
if (printHelp) {
jcw.printUsage(subCommander);
return;
}
if (install != null) {
installPlugin(install);
}
if (installJar != null) {
installPlugin("file:" + installJar);
}
if (uninstall != null) {
boolean uninstalled = JadxPluginsTools.getInstance().uninstall(uninstall);
System.out.println(uninstalled ? "Uninstalled" : "Plugin not found");
}
if (update) {
List<JadxPluginUpdate> updates = JadxPluginsTools.getInstance().updateAll();
if (updates.isEmpty()) {
System.out.println("No updates");
} else {
System.out.println("Installed updates: " + updates.size());
for (JadxPluginUpdate update : updates) {
System.out.println(" " + update.getPluginId() + ": " + update.getOldVersion() + " -> " + update.getNewVersion());
}
}
}
if (list) {
List<JadxPluginMetadata> installed = JadxPluginsTools.getInstance().getInstalled();
System.out.println("Installed plugins: " + installed.size());
for (JadxPluginMetadata plugin : installed) {
System.out.println(" " + plugin.getPluginId() + ":" + plugin.getVersion()
+ " - " + plugin.getName() + ": " + plugin.getDescription());
}
}
}
private void installPlugin(String locationId) {
JadxPluginMetadata plugin = JadxPluginsTools.getInstance().install(locationId);
System.out.println("Plugin installed: " + plugin.getPluginId() + ":" + plugin.getVersion());
}
}

View File

@ -0,0 +1,11 @@
package jadx.cli.commands;
import com.beust.jcommander.JCommander;
import jadx.cli.JCommanderWrapper;
public interface ICommand {
String name();
void process(JCommanderWrapper<?> jcw, JCommander subCommander);
}

View File

@ -25,6 +25,8 @@ import jadx.api.deobf.IAliasProvider;
import jadx.api.deobf.IRenameCondition;
import jadx.api.impl.AnnotatedCodeWriter;
import jadx.api.impl.InMemoryCodeCache;
import jadx.api.plugins.loader.JadxBasePluginLoader;
import jadx.api.plugins.loader.JadxPluginLoader;
import jadx.api.usage.IUsageInfoCache;
import jadx.api.usage.impl.InMemoryUsageInfoCache;
import jadx.core.deobf.DeobfAliasProvider;
@ -150,6 +152,8 @@ public class JadxArgs implements Closeable {
private Map<String, String> pluginOptions = new HashMap<>();
private JadxPluginLoader pluginLoader = new JadxBasePluginLoader();
public JadxArgs() {
// use default options
}
@ -170,6 +174,9 @@ public class JadxArgs implements Closeable {
if (usageInfoCache != null) {
usageInfoCache.close();
}
if (pluginLoader != null) {
pluginLoader.close();
}
} catch (Exception e) {
LOG.error("Failed to close JadxArgs", e);
} finally {
@ -634,6 +641,14 @@ public class JadxArgs implements Closeable {
this.pluginOptions = pluginOptions;
}
public JadxPluginLoader getPluginLoader() {
return pluginLoader;
}
public void setPluginLoader(JadxPluginLoader pluginLoader) {
this.pluginLoader = pluginLoader;
}
/**
* Hash of all options that can change result code
*/

View File

@ -182,7 +182,7 @@ public final class JadxDecompiler implements Closeable {
private void loadPlugins() {
pluginManager.providesSuggestion("java-input", args.isUseDxInput() ? "java-convert" : "java-input");
pluginManager.load();
pluginManager.load(args.getPluginLoader());
if (LOG.isDebugEnabled()) {
LOG.debug("Resolved plugins: {}", pluginManager.getResolvedPluginContexts());
}

View File

@ -0,0 +1,29 @@
package jadx.api.plugins.loader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;
import jadx.api.plugins.JadxPlugin;
/**
* Loading plugins from current classpath
*/
public class JadxBasePluginLoader implements JadxPluginLoader {
@Override
public List<JadxPlugin> load() {
List<JadxPlugin> list = new ArrayList<>();
ServiceLoader<JadxPlugin> plugins = ServiceLoader.load(JadxPlugin.class);
for (JadxPlugin plugin : plugins) {
list.add(plugin);
}
return list;
}
@Override
public void close() throws IOException {
// nothing to close
}
}

View File

@ -0,0 +1,11 @@
package jadx.api.plugins.loader;
import java.io.Closeable;
import java.util.List;
import jadx.api.plugins.JadxPlugin;
public interface JadxPluginLoader extends Closeable {
List<JadxPlugin> load();
}

View File

@ -4,7 +4,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ServiceLoader;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
@ -18,6 +17,7 @@ import jadx.api.JadxDecompiler;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.gui.JadxGuiContext;
import jadx.api.plugins.input.JadxCodeInput;
import jadx.api.plugins.loader.JadxPluginLoader;
import jadx.api.plugins.options.JadxPluginOptions;
import jadx.api.plugins.options.OptionDescription;
import jadx.core.utils.exceptions.JadxRuntimeException;
@ -43,10 +43,9 @@ public class JadxPluginManager {
provideSuggestions.put(provides, pluginId);
}
public void load() {
public void load(JadxPluginLoader pluginLoader) {
allPlugins.clear();
ServiceLoader<JadxPlugin> jadxPlugins = ServiceLoader.load(JadxPlugin.class);
for (JadxPlugin plugin : jadxPlugins) {
for (JadxPlugin plugin : pluginLoader.load()) {
addPlugin(plugin);
}
resolve();

View File

@ -9,6 +9,7 @@ plugins {
dependencies {
implementation(project(':jadx-core'))
implementation(project(':jadx-cli'))
implementation(project(':jadx-plugins-tools'))
// import mappings
implementation project(':jadx-plugins:jadx-rename-mappings')

View File

@ -36,6 +36,7 @@ import jadx.gui.settings.JadxProject;
import jadx.gui.settings.JadxSettings;
import jadx.gui.ui.MainWindow;
import jadx.gui.utils.CacheObject;
import jadx.plugins.tools.JadxExternalPluginsLoader;
import static jadx.core.dex.nodes.ProcessState.GENERATED_AND_UNLOADED;
import static jadx.core.dex.nodes.ProcessState.NOT_LOADED;
@ -61,6 +62,7 @@ public class JadxWrapper {
synchronized (DECOMPILER_UPDATE_SYNC) {
JadxProject project = getProject();
JadxArgs jadxArgs = getSettings().toJadxArgs();
jadxArgs.setPluginLoader(new JadxExternalPluginsLoader());
project.fillJadxArgs(jadxArgs);
decompiler = new JadxDecompiler(jadxArgs);

View File

@ -10,6 +10,7 @@ import jadx.api.JadxDecompiler;
import jadx.core.plugins.JadxPluginManager;
import jadx.core.plugins.PluginContext;
import jadx.gui.JadxWrapper;
import jadx.plugins.tools.JadxExternalPluginsLoader;
/**
* Collect options from all plugins.
@ -32,7 +33,7 @@ public class CollectPluginOptions {
// collect and init not loaded plugins in new context
try (JadxDecompiler decompiler = new JadxDecompiler(new JadxArgs())) {
JadxPluginManager pluginManager = decompiler.getPluginManager();
pluginManager.load();
pluginManager.load(new JadxExternalPluginsLoader());
SortedSet<PluginContext> missingPlugins = new TreeSet<>();
for (PluginContext context : pluginManager.getAllPluginContexts()) {
if (!allPlugins.contains(context)) {

View File

@ -0,0 +1,10 @@
plugins {
id("jadx-library")
}
dependencies {
api(project(":jadx-core"))
implementation("dev.dirs:directories:26")
implementation("com.google.code.gson:gson:2.10.1")
}

View File

@ -0,0 +1,107 @@
package jadx.plugins.tools;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.loader.JadxPluginLoader;
import jadx.core.utils.exceptions.JadxRuntimeException;
public class JadxExternalPluginsLoader implements JadxPluginLoader {
private static final Logger LOG = LoggerFactory.getLogger(JadxExternalPluginsLoader.class);
private final List<URLClassLoader> classLoaders = new ArrayList<>();
@Override
public List<JadxPlugin> load() {
close();
long start = System.currentTimeMillis();
Map<Class<? extends JadxPlugin>, JadxPlugin> map = new HashMap<>();
ClassLoader classLoader = JadxPluginsTools.class.getClassLoader();
loadFromClsLoader(map, classLoader);
loadInstalledPlugins(map, classLoader);
List<JadxPlugin> list = new ArrayList<>(map.size());
list.addAll(map.values());
list.sort(Comparator.comparing(p -> p.getClass().getSimpleName()));
if (LOG.isDebugEnabled()) {
LOG.debug("Collected {} plugins in {}ms", list.size(), System.currentTimeMillis() - start);
}
return list;
}
/**
* TODO: find a better way to load only plugin from single jar without plugins from parent
* classloader
*/
public JadxPlugin loadFromJar(Path jar) {
Map<Class<? extends JadxPlugin>, JadxPlugin> map = new HashMap<>();
ClassLoader classLoader = JadxPluginsTools.class.getClassLoader();
loadFromClsLoader(map, classLoader);
Set<Class<? extends JadxPlugin>> clspPlugins = new HashSet<>(map.keySet());
try (URLClassLoader pluginClassLoader = loadFromJar(map, classLoader, jar)) {
return map.entrySet().stream()
.filter(entry -> !clspPlugins.contains(entry.getKey()))
.findFirst()
.map(Map.Entry::getValue)
.orElseThrow(() -> new RuntimeException("No plugin found in jar: " + jar));
} catch (IOException e) {
throw new RuntimeException("Failed to load plugin jar: " + jar, e);
}
}
private void loadFromClsLoader(Map<Class<? extends JadxPlugin>, JadxPlugin> map, ClassLoader classLoader) {
ServiceLoader.load(JadxPlugin.class, classLoader)
.stream()
.filter(p -> !map.containsKey(p.type()))
.forEach(p -> map.put(p.type(), p.get()));
}
private void loadInstalledPlugins(Map<Class<? extends JadxPlugin>, JadxPlugin> map, ClassLoader classLoader) {
List<Path> jars = JadxPluginsTools.getInstance().getAllPluginJars();
for (Path jar : jars) {
classLoaders.add(loadFromJar(map, classLoader, jar));
}
}
private URLClassLoader loadFromJar(Map<Class<? extends JadxPlugin>, JadxPlugin> map, ClassLoader classLoader, Path jar) {
try {
File jarFile = jar.toFile();
URL[] urls = new URL[] { jarFile.toURI().toURL() };
URLClassLoader pluginClsLoader = new URLClassLoader("jadx-plugin:" + jarFile.getName(), urls, classLoader);
loadFromClsLoader(map, pluginClsLoader);
return pluginClsLoader;
} catch (Exception e) {
throw new JadxRuntimeException("Failed to load plugins, jar: " + jar, e);
}
}
@Override
public void close() {
try {
for (URLClassLoader classLoader : classLoaders) {
try {
classLoader.close();
} catch (Exception e) {
// ignore
}
}
} finally {
classLoaders.clear();
}
}
}

View File

@ -0,0 +1,252 @@
package jadx.plugins.tools;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.Writer;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.jetbrains.annotations.Nullable;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import dev.dirs.ProjectDirectories;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.JadxPluginInfo;
import jadx.core.utils.files.FileUtils;
import jadx.plugins.tools.data.JadxInstalledPlugins;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.data.JadxPluginUpdate;
import jadx.plugins.tools.resolvers.IJadxPluginResolver;
import jadx.plugins.tools.resolvers.ResolversRegistry;
import static jadx.core.utils.files.FileUtils.makeDirs;
public class JadxPluginsTools {
private static final JadxPluginsTools INSTANCE = new JadxPluginsTools();
public static JadxPluginsTools getInstance() {
return INSTANCE;
}
private final Path pluginsJson;
private final Path dropins;
private final Path installed;
private JadxPluginsTools() {
ProjectDirectories jadxDirs = ProjectDirectories.from("io.github", "skylot", "jadx");
Path plugins = Paths.get(jadxDirs.configDir, "plugins"); // TODO: use dataDir?
makeDirs(plugins);
pluginsJson = plugins.resolve("plugins.json");
dropins = plugins.resolve("dropins");
makeDirs(dropins);
installed = plugins.resolve("installed");
makeDirs(installed);
}
public JadxPluginMetadata install(String locationId) {
JadxPluginMetadata pluginMetadata = ResolversRegistry.resolve(locationId)
.orElseThrow(() -> new RuntimeException("Failed to resolve locationId: " + locationId));
install(pluginMetadata);
return pluginMetadata;
}
public List<JadxPluginUpdate> updateAll() {
JadxInstalledPlugins plugins = loadPluginsJson();
int size = plugins.getInstalled().size();
List<JadxPluginUpdate> updates = new ArrayList<>(size);
List<JadxPluginMetadata> newList = new ArrayList<>(size);
for (JadxPluginMetadata plugin : plugins.getInstalled()) {
JadxPluginMetadata newVersion = update(plugin);
if (newVersion != null) {
updates.add(new JadxPluginUpdate(plugin, newVersion));
newList.add(newVersion);
} else {
newList.add(plugin);
}
}
if (!updates.isEmpty()) {
plugins.setUpdated(System.currentTimeMillis());
plugins.setInstalled(newList);
savePluginsJson(plugins);
}
return updates;
}
public Optional<JadxPluginUpdate> update(String pluginId) {
JadxInstalledPlugins plugins = loadPluginsJson();
JadxPluginMetadata plugin = plugins.getInstalled().stream()
.filter(p -> p.getPluginId().equals(pluginId))
.findFirst()
.orElseThrow(() -> new RuntimeException("Plugin not found: " + pluginId));
JadxPluginMetadata newVersion = update(plugin);
if (newVersion == null) {
return Optional.empty();
}
plugins.setUpdated(System.currentTimeMillis());
plugins.getInstalled().remove(plugin);
plugins.getInstalled().add(newVersion);
savePluginsJson(plugins);
return Optional.of(new JadxPluginUpdate(plugin, newVersion));
}
public boolean uninstall(String pluginId) {
JadxInstalledPlugins plugins = loadPluginsJson();
Optional<JadxPluginMetadata> found = plugins.getInstalled().stream()
.filter(p -> p.getPluginId().equals(pluginId))
.findFirst();
if (found.isEmpty()) {
return false;
}
JadxPluginMetadata plugin = found.get();
deletePluginJar(plugin);
plugins.getInstalled().remove(plugin);
savePluginsJson(plugins);
return true;
}
private void deletePluginJar(JadxPluginMetadata plugin) {
try {
Files.deleteIfExists(installed.resolve(plugin.getJar()));
} catch (IOException e) {
// ignore
}
}
public List<JadxPluginMetadata> getInstalled() {
return loadPluginsJson().getInstalled();
}
public List<Path> getAllPluginJars() {
List<Path> list = new ArrayList<>();
for (JadxPluginMetadata pluginMetadata : loadPluginsJson().getInstalled()) {
list.add(installed.resolve(pluginMetadata.getJar()));
}
collectFromDir(list, dropins);
return list;
}
private @Nullable JadxPluginMetadata update(JadxPluginMetadata plugin) {
IJadxPluginResolver resolver = ResolversRegistry.getById(plugin.getResolverId());
if (!resolver.isUpdateSupported()) {
return null;
}
Optional<JadxPluginMetadata> updateOpt = resolver.resolve(plugin.getLocationId());
if (updateOpt.isEmpty()) {
return null;
}
JadxPluginMetadata update = updateOpt.get();
if (update.getVersion().equals(plugin.getVersion())) {
return null;
}
install(update);
return update;
}
private void install(JadxPluginMetadata metadata) {
Path tmpJar;
if (needDownload(metadata.getJar())) {
tmpJar = FileUtils.createTempFile("plugin.jar");
downloadJar(metadata.getJar(), tmpJar);
} else {
tmpJar = Paths.get(metadata.getJar());
}
fillPluginInfoFromJar(metadata, tmpJar);
Path pluginJar = installed.resolve(metadata.getPluginId() + '-' + metadata.getVersion() + ".jar");
copyJar(tmpJar, pluginJar);
metadata.setJar(installed.relativize(pluginJar).toString());
JadxInstalledPlugins plugins = loadPluginsJson();
// remove previous version jar
plugins.getInstalled().stream()
.filter(p -> p.getPluginId().equals(metadata.getPluginId()))
.forEach(this::deletePluginJar);
plugins.getInstalled().remove(metadata);
plugins.getInstalled().add(metadata);
plugins.setUpdated(System.currentTimeMillis());
savePluginsJson(plugins);
}
private void fillPluginInfoFromJar(JadxPluginMetadata metadata, Path jar) {
try (JadxExternalPluginsLoader loader = new JadxExternalPluginsLoader()) {
JadxPlugin jadxPlugin = loader.loadFromJar(jar);
JadxPluginInfo pluginInfo = jadxPlugin.getPluginInfo();
metadata.setPluginId(pluginInfo.getPluginId());
metadata.setName(pluginInfo.getName());
metadata.setDescription(pluginInfo.getDescription());
}
}
private boolean needDownload(String jar) {
return jar.startsWith("https://") || jar.startsWith("http://");
}
private void downloadJar(String sourceJar, Path destPath) {
try (InputStream in = new URL(sourceJar).openStream()) {
Files.copy(in, destPath, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
throw new RuntimeException("Failed to download jar: " + sourceJar, e);
}
}
private void copyJar(Path sourceJar, Path destJar) {
try {
Files.copy(sourceJar, destJar, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
throw new RuntimeException("Failed to copy plugin jar: " + sourceJar + " to: " + destJar, e);
}
}
private static Gson buildGson() {
return new GsonBuilder().setPrettyPrinting().create();
}
private JadxInstalledPlugins loadPluginsJson() {
if (!Files.isRegularFile(pluginsJson)) {
return new JadxInstalledPlugins();
}
try (Reader reader = Files.newBufferedReader(pluginsJson, StandardCharsets.UTF_8)) {
return buildGson().fromJson(reader, JadxInstalledPlugins.class);
} catch (Exception e) {
throw new RuntimeException("Failed to read file: " + pluginsJson);
}
}
private void savePluginsJson(JadxInstalledPlugins data) {
if (data.getInstalled().isEmpty()) {
try {
Files.deleteIfExists(pluginsJson);
} catch (Exception e) {
throw new RuntimeException("Failed to remove file: " + pluginsJson, e);
}
return;
}
data.getInstalled().sort(null);
try (Writer writer = Files.newBufferedWriter(pluginsJson, StandardCharsets.UTF_8)) {
buildGson().toJson(data, writer);
} catch (Exception e) {
throw new RuntimeException("Error saving file: " + pluginsJson, e);
}
}
private static void collectFromDir(List<Path> list, Path dir) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(p -> p.getFileName().toString().endsWith(".jar")).forEach(list::add);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,27 @@
package jadx.plugins.tools.data;
import java.util.ArrayList;
import java.util.List;
public class JadxInstalledPlugins {
private long updated;
private List<JadxPluginMetadata> installed = new ArrayList<>();
public long getUpdated() {
return updated;
}
public void setUpdated(long updated) {
this.updated = updated;
}
public List<JadxPluginMetadata> getInstalled() {
return installed;
}
public void setInstalled(List<JadxPluginMetadata> installed) {
this.installed = installed;
}
}

View File

@ -0,0 +1,101 @@
package jadx.plugins.tools.data;
import org.jetbrains.annotations.NotNull;
public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
private String pluginId;
private String name;
private String description;
private String version;
private String locationId;
private String resolverId;
private String jar;
public String getPluginId() {
return pluginId;
}
public void setPluginId(String pluginId) {
this.pluginId = pluginId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getLocationId() {
return locationId;
}
public void setLocationId(String locationId) {
this.locationId = locationId;
}
public String getResolverId() {
return resolverId;
}
public void setResolverId(String resolverId) {
this.resolverId = resolverId;
}
public String getJar() {
return jar;
}
public void setJar(String jar) {
this.jar = jar;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof JadxPluginMetadata)) {
return false;
}
return pluginId.equals(((JadxPluginMetadata) other).pluginId);
}
@Override
public int hashCode() {
return pluginId.hashCode();
}
@Override
public int compareTo(@NotNull JadxPluginMetadata o) {
return pluginId.compareTo(o.pluginId);
}
@Override
public String toString() {
return "JadxPluginMetadata{"
+ "id=" + pluginId
+ ", name=" + name
+ ", version=" + version
+ ", locationId=" + locationId
+ ", jar=" + jar
+ '}';
}
}

View File

@ -0,0 +1,37 @@
package jadx.plugins.tools.data;
public class JadxPluginUpdate {
private final JadxPluginMetadata oldVersion;
private final JadxPluginMetadata newVersion;
public JadxPluginUpdate(JadxPluginMetadata oldVersion, JadxPluginMetadata newVersion) {
this.oldVersion = oldVersion;
this.newVersion = newVersion;
}
public JadxPluginMetadata getOld() {
return oldVersion;
}
public JadxPluginMetadata getNew() {
return newVersion;
}
public String getPluginId() {
return newVersion.getPluginId();
}
public String getOldVersion() {
return oldVersion.getVersion();
}
public String getNewVersion() {
return newVersion.getVersion();
}
@Override
public String toString() {
return "PluginUpdate{" + oldVersion.getPluginId()
+ ": " + oldVersion.getVersion() + " -> " + newVersion.getVersion() + "}";
}
}

View File

@ -0,0 +1,14 @@
package jadx.plugins.tools.resolvers;
import java.util.Optional;
import jadx.plugins.tools.data.JadxPluginMetadata;
public interface IJadxPluginResolver {
String id();
boolean isUpdateSupported();
Optional<JadxPluginMetadata> resolve(String locationId);
}

View File

@ -0,0 +1,28 @@
### Supported publish locations for Jadx plugins
---
#### GitHub release artifact
Pattern: `github:<owner>:<repo>[:<version>][:<artifact name prefix>]`
Examples: `github:skylot:jadx`, `github:skylot:jadx:sample-plugin` or `github:skylot:jadx:0.1.0`
`<version>` - exact version to install (optional), should be equal to release name
Artifact should have a name: `<artifact name prefix>-<release-version-name>.jar`.
Default value for `<artifact name prefix>` is a repo name,
`release-version-name` should have a `x.x.x` format.
---
#### Local file
Install local jar file.
Pattern: `file:<path to file>.jar`
Example: `file:/home/user/plugin.jar`
As alternative to install, plugin jars can be copied to `plugins/dropins` folder.

View File

@ -0,0 +1,41 @@
package jadx.plugins.tools.resolvers;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.resolvers.file.LocalFileResolver;
import jadx.plugins.tools.resolvers.github.GithubReleaseResolver;
public class ResolversRegistry {
private static final Map<String, IJadxPluginResolver> RESOLVERS_MAP = new TreeMap<>();
static {
register(new LocalFileResolver());
register(new GithubReleaseResolver());
}
private static void register(IJadxPluginResolver resolver) {
RESOLVERS_MAP.put(resolver.id(), resolver);
}
public static Optional<JadxPluginMetadata> resolve(String locationId) {
for (IJadxPluginResolver resolver : RESOLVERS_MAP.values()) {
Optional<JadxPluginMetadata> result = resolver.resolve(locationId);
if (result.isPresent()) {
return result;
}
}
return Optional.empty();
}
public static IJadxPluginResolver getById(String resolverId) {
IJadxPluginResolver resolver = RESOLVERS_MAP.get(resolverId);
if (resolver == null) {
throw new IllegalArgumentException("Unknown resolverId: " + resolverId);
}
return resolver;
}
}

View File

@ -0,0 +1,37 @@
package jadx.plugins.tools.resolvers.file;
import java.io.File;
import java.util.Optional;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.resolvers.IJadxPluginResolver;
import static jadx.plugins.tools.utils.PluginsUtils.removePrefix;
public class LocalFileResolver implements IJadxPluginResolver {
@Override
public String id() {
return "file";
}
@Override
public boolean isUpdateSupported() {
return false;
}
@Override
public Optional<JadxPluginMetadata> resolve(String locationId) {
if (!locationId.startsWith("file:") || !locationId.endsWith(".jar")) {
return Optional.empty();
}
File jarFile = new File(removePrefix(locationId, "file:"));
if (!jarFile.isFile()) {
throw new RuntimeException("File not found: " + jarFile.getAbsolutePath());
}
JadxPluginMetadata metadata = new JadxPluginMetadata();
metadata.setLocationId(locationId);
metadata.setResolverId(id());
metadata.setJar(jarFile.getAbsolutePath());
return Optional.of(metadata);
}
}

View File

@ -0,0 +1,129 @@
package jadx.plugins.tools.resolvers.github;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.lang.reflect.Type;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.resolvers.IJadxPluginResolver;
import jadx.plugins.tools.resolvers.github.data.Asset;
import jadx.plugins.tools.resolvers.github.data.Release;
import static jadx.plugins.tools.utils.PluginsUtils.removePrefix;
public class GithubReleaseResolver implements IJadxPluginResolver {
private static final String GITHUB_API_URL = "https://api.github.com/";
private static final Pattern VERSION_PATTERN = Pattern.compile("v?\\d+\\.\\d+(\\.\\d+)?");
private static final Type RELEASE_TYPE = new TypeToken<Release>() {
}.getType();
private static final Type RELEASE_LIST_TYPE = new TypeToken<List<Release>>() {
}.getType();
@Override
public Optional<JadxPluginMetadata> resolve(String locationId) {
if (!locationId.startsWith("github:")) {
return Optional.empty();
}
String[] parts = locationId.split(":");
if (parts.length < 3) {
return Optional.empty();
}
String owner = parts[1];
String project = parts[2];
String version = null;
String artifactPrefix = project;
if (parts.length >= 4) {
String part = parts[3];
if (VERSION_PATTERN.matcher(part).matches()) {
version = part;
if (parts.length >= 5) {
artifactPrefix = parts[4];
}
} else {
artifactPrefix = part;
}
}
Release release = getRelease(owner, project, version);
String releaseVersion = removePrefix(release.getName(), "v");
String artifactName = artifactPrefix + '-' + releaseVersion + ".jar";
Asset asset = release.getAssets().stream()
.filter(a -> a.getName().equals(artifactName))
.findFirst()
.orElseThrow(() -> new RuntimeException("Release artifact with name '" + artifactName + "' not found"));
JadxPluginMetadata metadata = new JadxPluginMetadata();
metadata.setResolverId(id());
metadata.setVersion(releaseVersion);
metadata.setLocationId(buildLocationId(owner, project, artifactPrefix)); // exclude version for later updates
metadata.setJar(asset.getDownloadUrl());
return Optional.of(metadata);
}
@NotNull
private static String buildLocationId(String owner, String project, String artifactPrefix) {
if (project.equals(artifactPrefix)) {
return "github:" + owner + ':' + project;
}
return "github:" + owner + ':' + project + ':' + artifactPrefix;
}
private static Release getRelease(String owner, String project, @Nullable String version) {
String projectUrl = GITHUB_API_URL + "repos/" + owner + "/" + project;
if (version == null) {
// get latest version
return get(projectUrl + "/releases/latest", RELEASE_TYPE);
}
// search version among all releases (by name)
List<Release> releases = get(projectUrl + "/releases", RELEASE_LIST_TYPE);
return releases.stream()
.filter(r -> r.getName().equals(version))
.findFirst()
.orElseThrow(() -> new RuntimeException("Release with version: " + version + " not found."
+ " Available versions: " + releases.stream().map(Release::getName).collect(Collectors.joining(", "))));
}
private static <T> T get(String url, Type type) {
HttpURLConnection con;
try {
con = (HttpURLConnection) new URL(url).openConnection();
con.setRequestMethod("GET");
int code = con.getResponseCode();
if (code != 200) {
// TODO: support redirects?
throw new RuntimeException("Request failed, response: " + code + ", url: " + url);
}
} catch (IOException e) {
throw new RuntimeException("Request failed, url: " + url, e);
}
try (Reader reader = new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)) {
return new Gson().fromJson(reader, type);
} catch (Exception e) {
throw new RuntimeException("Failed to parse response, url: " + url, e);
}
}
@Override
public String id() {
return "github-release";
}
@Override
public boolean isUpdateSupported() {
return true;
}
}

View File

@ -0,0 +1,63 @@
package jadx.plugins.tools.resolvers.github.data;
import com.google.gson.annotations.SerializedName;
public class Asset {
private int id;
private String name;
private long size;
@SerializedName("browser_download_url")
private String downloadUrl;
@SerializedName("created_at")
private String createdAt;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
public String getDownloadUrl() {
return downloadUrl;
}
public void setDownloadUrl(String downloadUrl) {
this.downloadUrl = downloadUrl;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
@Override
public String toString() {
return name
+ ", size: " + String.format("%.2fMB", size / 1024. / 1024.)
+ ", url: " + downloadUrl
+ ", date: " + createdAt;
}
}

View File

@ -0,0 +1,44 @@
package jadx.plugins.tools.resolvers.github.data;
import java.util.List;
public class Release {
private int id;
private String name;
private List<Asset> assets;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public List<Asset> getAssets() {
return assets;
}
public void setAssets(List<Asset> assets) {
this.assets = assets;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(name);
for (Asset asset : getAssets()) {
sb.append("\n ");
sb.append(asset);
}
return sb.toString();
}
}

View File

@ -0,0 +1,11 @@
package jadx.plugins.tools.utils;
public class PluginsUtils {
public static String removePrefix(String str, String prefix) {
if (str.startsWith(prefix)) {
return str.substring(prefix.length());
}
return str;
}
}

View File

@ -8,6 +8,8 @@ include("jadx-core")
include("jadx-cli")
include("jadx-gui")
include("jadx-plugins-tools")
include("jadx-plugins:jadx-input-api")
include("jadx-plugins:jadx-dex-input")
include("jadx-plugins:jadx-java-input")