feat(plugins): add API for search/use other plugins
This commit is contained in:
@@ -46,6 +46,7 @@ import jadx.core.dex.nodes.RootNode;
|
||||
import jadx.core.dex.visitors.SaveCode;
|
||||
import jadx.core.export.ExportGradleTask;
|
||||
import jadx.core.plugins.JadxPluginManager;
|
||||
import jadx.core.plugins.PluginContext;
|
||||
import jadx.core.plugins.events.JadxEventsImpl;
|
||||
import jadx.core.utils.DecompilerScheduler;
|
||||
import jadx.core.utils.Utils;
|
||||
@@ -147,10 +148,16 @@ public final class JadxDecompiler implements Closeable {
|
||||
List<Path> inputPaths = Utils.collectionMap(args.getInputFiles(), File::toPath);
|
||||
List<Path> inputFiles = FileUtils.expandDirs(inputPaths);
|
||||
long start = System.currentTimeMillis();
|
||||
for (JadxCodeInput codeLoader : pluginManager.getCodeInputs()) {
|
||||
ICodeLoader loader = codeLoader.loadFiles(inputFiles);
|
||||
if (loader != null && !loader.isEmpty()) {
|
||||
loadedInputs.add(loader);
|
||||
for (PluginContext plugin : pluginManager.getResolvedPluginContexts()) {
|
||||
for (JadxCodeInput codeLoader : plugin.getCodeInputs()) {
|
||||
try {
|
||||
ICodeLoader loader = codeLoader.loadFiles(inputFiles);
|
||||
if (loader != null && !loader.isEmpty()) {
|
||||
loadedInputs.add(loader);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new JadxRuntimeException("Failed to load code for plugin: " + plugin, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadedInputs.addAll(customCodeLoaders);
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import jadx.api.JadxArgs;
|
||||
import jadx.api.JadxDecompiler;
|
||||
import jadx.api.plugins.data.IJadxPlugins;
|
||||
import jadx.api.plugins.events.IJadxEvents;
|
||||
import jadx.api.plugins.gui.JadxGuiContext;
|
||||
import jadx.api.plugins.input.JadxCodeInput;
|
||||
@@ -31,11 +32,19 @@ public interface JadxPluginContext {
|
||||
*/
|
||||
void registerInputsHashSupplier(Supplier<String> supplier);
|
||||
|
||||
/**
|
||||
* Access to jadx-gui specific methods
|
||||
*/
|
||||
@Nullable
|
||||
JadxGuiContext getGuiContext();
|
||||
|
||||
/**
|
||||
* Subscribe and send events
|
||||
*/
|
||||
IJadxEvents events();
|
||||
|
||||
@Nullable
|
||||
JadxGuiContext getGuiContext();
|
||||
/**
|
||||
* Access to registered plugins and runtime data
|
||||
*/
|
||||
IJadxPlugins plugins();
|
||||
}
|
||||
|
||||
@@ -11,12 +11,16 @@ public class JadxPluginInfoBuilder {
|
||||
private String homepage = "";
|
||||
private @Nullable String provides;
|
||||
|
||||
public JadxPluginInfoBuilder() {
|
||||
/**
|
||||
* Start building method
|
||||
*/
|
||||
public static JadxPluginInfoBuilder pluginId(String pluginId) {
|
||||
JadxPluginInfoBuilder builder = new JadxPluginInfoBuilder();
|
||||
builder.pluginId = Objects.requireNonNull(pluginId);
|
||||
return builder;
|
||||
}
|
||||
|
||||
public JadxPluginInfoBuilder pluginId(String pluginId) {
|
||||
this.pluginId = Objects.requireNonNull(pluginId);
|
||||
return this;
|
||||
private JadxPluginInfoBuilder() {
|
||||
}
|
||||
|
||||
public JadxPluginInfoBuilder name(String name) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package jadx.api.plugins.data;
|
||||
|
||||
import jadx.api.plugins.JadxPlugin;
|
||||
|
||||
public interface IJadxPlugins {
|
||||
|
||||
JadxPluginRuntimeData getById(String pluginId);
|
||||
|
||||
JadxPluginRuntimeData getProviding(String provideId);
|
||||
|
||||
<P extends JadxPlugin> P getInstance(Class<P> pluginCls);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package jadx.api.plugins.data;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import jadx.api.plugins.JadxPlugin;
|
||||
import jadx.api.plugins.JadxPluginInfo;
|
||||
import jadx.api.plugins.input.ICodeLoader;
|
||||
import jadx.api.plugins.input.JadxCodeInput;
|
||||
import jadx.api.plugins.options.JadxPluginOptions;
|
||||
|
||||
/**
|
||||
* Runtime plugin data.
|
||||
*/
|
||||
public interface JadxPluginRuntimeData {
|
||||
boolean isInitialized();
|
||||
|
||||
String getPluginId();
|
||||
|
||||
JadxPlugin getPluginInstance();
|
||||
|
||||
JadxPluginInfo getPluginInfo();
|
||||
|
||||
List<JadxCodeInput> getCodeInputs();
|
||||
|
||||
@Nullable
|
||||
JadxPluginOptions getOptions();
|
||||
|
||||
String getInputsHash();
|
||||
|
||||
/**
|
||||
* Convenient method to simplify code loading from custom files.
|
||||
*/
|
||||
ICodeLoader loadCodeFiles(List<Path> files, @Nullable Closeable closeable);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class JadxPluginManager {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(JadxPluginManager.class);
|
||||
|
||||
private final JadxDecompiler decompiler;
|
||||
private final JadxPluginsData pluginsData;
|
||||
private final SortedSet<PluginContext> allPlugins = new TreeSet<>();
|
||||
private final SortedSet<PluginContext> resolvedPlugins = new TreeSet<>();
|
||||
private final Map<String, String> provideSuggestions = new TreeMap<>();
|
||||
@@ -33,6 +34,7 @@ public class JadxPluginManager {
|
||||
|
||||
public JadxPluginManager(JadxDecompiler decompiler) {
|
||||
this.decompiler = decompiler;
|
||||
this.pluginsData = new JadxPluginsData(decompiler, this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +60,7 @@ public class JadxPluginManager {
|
||||
}
|
||||
|
||||
private PluginContext addPlugin(JadxPlugin plugin) {
|
||||
PluginContext pluginContext = new PluginContext(decompiler, plugin);
|
||||
PluginContext pluginContext = new PluginContext(decompiler, pluginsData, plugin);
|
||||
LOG.debug("Loading plugin: {}", pluginContext);
|
||||
if (!allPlugins.add(pluginContext)) {
|
||||
throw new IllegalArgumentException("Duplicate plugin id: " + pluginContext + ", class " + plugin.getClass());
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package jadx.core.plugins;
|
||||
|
||||
import jadx.api.JadxDecompiler;
|
||||
import jadx.api.plugins.JadxPlugin;
|
||||
import jadx.api.plugins.data.IJadxPlugins;
|
||||
import jadx.api.plugins.data.JadxPluginRuntimeData;
|
||||
import jadx.core.utils.exceptions.JadxRuntimeException;
|
||||
|
||||
public class JadxPluginsData implements IJadxPlugins {
|
||||
|
||||
private final JadxDecompiler decompiler;
|
||||
private final JadxPluginManager pluginManager;
|
||||
|
||||
public JadxPluginsData(JadxDecompiler decompiler, JadxPluginManager pluginManager) {
|
||||
this.decompiler = decompiler;
|
||||
this.pluginManager = pluginManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JadxPluginRuntimeData getById(String pluginId) {
|
||||
return pluginManager.getResolvedPluginContexts()
|
||||
.stream()
|
||||
.filter(p -> p.getPluginId().equals(pluginId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new JadxRuntimeException("Plugin with id '" + pluginId + "' not found"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JadxPluginRuntimeData getProviding(String provideId) {
|
||||
return pluginManager.getResolvedPluginContexts()
|
||||
.stream()
|
||||
.filter(p -> p.getPluginInfo().getProvides().equals(provideId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new JadxRuntimeException("Plugin providing '" + provideId + "' not found"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public <P extends JadxPlugin> P getInstance(Class<P> pluginCls) {
|
||||
return pluginManager.getResolvedPluginContexts()
|
||||
.stream()
|
||||
.filter(p -> p.getPluginInstance().getClass().equals(pluginCls))
|
||||
.map(p -> ((P) p.getPluginInstance()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new JadxRuntimeException("Plugin class '" + pluginCls + "' not found"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package jadx.core.plugins;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -13,18 +15,24 @@ import jadx.api.JadxDecompiler;
|
||||
import jadx.api.plugins.JadxPlugin;
|
||||
import jadx.api.plugins.JadxPluginContext;
|
||||
import jadx.api.plugins.JadxPluginInfo;
|
||||
import jadx.api.plugins.data.IJadxPlugins;
|
||||
import jadx.api.plugins.data.JadxPluginRuntimeData;
|
||||
import jadx.api.plugins.events.IJadxEvents;
|
||||
import jadx.api.plugins.gui.JadxGuiContext;
|
||||
import jadx.api.plugins.input.ICodeLoader;
|
||||
import jadx.api.plugins.input.JadxCodeInput;
|
||||
import jadx.api.plugins.input.data.impl.MergeCodeLoader;
|
||||
import jadx.api.plugins.options.JadxPluginOptions;
|
||||
import jadx.api.plugins.options.OptionDescription;
|
||||
import jadx.api.plugins.options.OptionFlag;
|
||||
import jadx.api.plugins.pass.JadxPass;
|
||||
import jadx.core.utils.Utils;
|
||||
import jadx.core.utils.exceptions.JadxRuntimeException;
|
||||
import jadx.core.utils.files.FileUtils;
|
||||
|
||||
public class PluginContext implements JadxPluginContext, Comparable<PluginContext> {
|
||||
public class PluginContext implements JadxPluginContext, JadxPluginRuntimeData, Comparable<PluginContext> {
|
||||
private final JadxDecompiler decompiler;
|
||||
private final JadxPluginsData pluginsData;
|
||||
private final JadxPlugin plugin;
|
||||
private final JadxPluginInfo pluginInfo;
|
||||
private @Nullable JadxGuiContext guiContext;
|
||||
@@ -35,8 +43,9 @@ public class PluginContext implements JadxPluginContext, Comparable<PluginContex
|
||||
|
||||
private boolean initialized;
|
||||
|
||||
PluginContext(JadxDecompiler decompiler, JadxPlugin plugin) {
|
||||
PluginContext(JadxDecompiler decompiler, JadxPluginsData pluginsData, JadxPlugin plugin) {
|
||||
this.decompiler = decompiler;
|
||||
this.pluginsData = pluginsData;
|
||||
this.plugin = plugin;
|
||||
this.pluginInfo = plugin.getPluginInfo();
|
||||
}
|
||||
@@ -46,6 +55,7 @@ public class PluginContext implements JadxPluginContext, Comparable<PluginContex
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInitialized() {
|
||||
return initialized;
|
||||
}
|
||||
@@ -70,6 +80,7 @@ public class PluginContext implements JadxPluginContext, Comparable<PluginContex
|
||||
this.codeInputs.add(codeInput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<JadxCodeInput> getCodeInputs() {
|
||||
return codeInputs;
|
||||
}
|
||||
@@ -89,6 +100,7 @@ public class PluginContext implements JadxPluginContext, Comparable<PluginContex
|
||||
this.inputsHashSupplier = supplier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getInputsHash() {
|
||||
if (inputsHashSupplier == null) {
|
||||
return defaultOptionsHash();
|
||||
@@ -128,22 +140,38 @@ public class PluginContext implements JadxPluginContext, Comparable<PluginContex
|
||||
this.guiContext = guiContext;
|
||||
}
|
||||
|
||||
public JadxPlugin getPlugin() {
|
||||
@Override
|
||||
public JadxPlugin getPluginInstance() {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JadxPluginInfo getPluginInfo() {
|
||||
return pluginInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPluginId() {
|
||||
return pluginInfo.getPluginId();
|
||||
}
|
||||
|
||||
public JadxPluginOptions getOptions() {
|
||||
@Override
|
||||
public @Nullable JadxPluginOptions getOptions() {
|
||||
return options;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IJadxPlugins plugins() {
|
||||
return pluginsData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ICodeLoader loadCodeFiles(List<Path> files, @Nullable Closeable closeable) {
|
||||
return new MergeCodeLoader(
|
||||
Utils.collectionMap(codeInputs, codeInput -> codeInput.loadFiles(files)),
|
||||
closeable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package jadx.api.plugins.input.data.impl;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import jadx.api.plugins.input.ICodeLoader;
|
||||
import jadx.api.plugins.input.data.IClassData;
|
||||
|
||||
public class MergeCodeLoader implements ICodeLoader {
|
||||
|
||||
private final List<ICodeLoader> codeLoaders;
|
||||
private final @Nullable Closeable closeable;
|
||||
|
||||
public MergeCodeLoader(List<ICodeLoader> codeLoaders) {
|
||||
this(codeLoaders, null);
|
||||
}
|
||||
|
||||
public MergeCodeLoader(List<ICodeLoader> codeLoaders, @Nullable Closeable closeable) {
|
||||
this.codeLoaders = codeLoaders;
|
||||
this.closeable = closeable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitClasses(Consumer<IClassData> consumer) {
|
||||
for (ICodeLoader codeLoader : codeLoaders) {
|
||||
codeLoader.visitClasses(consumer);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
for (ICodeLoader codeLoader : codeLoaders) {
|
||||
if (!codeLoader.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
for (ICodeLoader codeLoader : codeLoaders) {
|
||||
codeLoader.close();
|
||||
}
|
||||
if (closeable != null) {
|
||||
closeable.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -6,30 +6,33 @@ import java.util.List;
|
||||
import jadx.api.plugins.JadxPlugin;
|
||||
import jadx.api.plugins.JadxPluginContext;
|
||||
import jadx.api.plugins.JadxPluginInfo;
|
||||
import jadx.api.plugins.JadxPluginInfoBuilder;
|
||||
import jadx.api.plugins.data.JadxPluginRuntimeData;
|
||||
import jadx.api.plugins.input.ICodeLoader;
|
||||
import jadx.api.plugins.input.JadxCodeInput;
|
||||
import jadx.api.plugins.input.data.impl.EmptyCodeLoader;
|
||||
import jadx.plugins.input.dex.DexInputPlugin;
|
||||
|
||||
public class JavaConvertPlugin implements JadxPlugin, JadxCodeInput {
|
||||
|
||||
public static final String PLUGIN_ID = "java-convert";
|
||||
|
||||
private final DexInputPlugin dexInput = new DexInputPlugin();
|
||||
private final JavaConvertOptions options = new JavaConvertOptions();
|
||||
private final JavaConvertLoader loader = new JavaConvertLoader(options);
|
||||
|
||||
private JadxPluginRuntimeData dexInput;
|
||||
|
||||
@Override
|
||||
public JadxPluginInfo getPluginInfo() {
|
||||
return new JadxPluginInfo(
|
||||
PLUGIN_ID,
|
||||
"Java Convert",
|
||||
"Convert .class, .jar and .aar files to dex",
|
||||
"java-input");
|
||||
return JadxPluginInfoBuilder.pluginId(PLUGIN_ID)
|
||||
.name("Java Convert")
|
||||
.description("Convert .class, .jar and .aar files to dex")
|
||||
.provides("java-input")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(JadxPluginContext context) {
|
||||
dexInput = context.plugins().getById(DexInputPlugin.PLUGIN_ID);
|
||||
context.registerOptions(options);
|
||||
context.addCodeInput(this);
|
||||
}
|
||||
@@ -41,6 +44,6 @@ public class JavaConvertPlugin implements JadxPlugin, JadxCodeInput {
|
||||
result.close();
|
||||
return EmptyCodeLoader.INSTANCE;
|
||||
}
|
||||
return dexInput.loadFiles(result.getConverted(), result);
|
||||
return dexInput.loadCodeFiles(result.getConverted(), result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,5 @@ plugins {
|
||||
dependencies {
|
||||
api(project(":jadx-core"))
|
||||
|
||||
implementation(project(":jadx-plugins:jadx-java-input"))
|
||||
|
||||
implementation("io.github.skylot:raung-asm:0.1.0")
|
||||
}
|
||||
|
||||
+10
-17
@@ -1,17 +1,12 @@
|
||||
package jadx.plugins.input.raung;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import jadx.api.plugins.JadxPlugin;
|
||||
import jadx.api.plugins.JadxPluginContext;
|
||||
import jadx.api.plugins.JadxPluginInfo;
|
||||
import jadx.api.plugins.input.ICodeLoader;
|
||||
import jadx.api.plugins.input.JadxCodeInput;
|
||||
import jadx.api.plugins.data.JadxPluginRuntimeData;
|
||||
import jadx.api.plugins.input.data.impl.EmptyCodeLoader;
|
||||
import jadx.plugins.input.java.JavaInputPlugin;
|
||||
|
||||
public class RaungInputPlugin implements JadxPlugin, JadxCodeInput {
|
||||
public class RaungInputPlugin implements JadxPlugin {
|
||||
|
||||
@Override
|
||||
public JadxPluginInfo getPluginInfo() {
|
||||
@@ -20,15 +15,13 @@ public class RaungInputPlugin implements JadxPlugin, JadxCodeInput {
|
||||
|
||||
@Override
|
||||
public void init(JadxPluginContext context) {
|
||||
context.addCodeInput(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ICodeLoader loadFiles(List<Path> input) {
|
||||
RaungConvert convert = new RaungConvert();
|
||||
if (!convert.execute(input)) {
|
||||
return EmptyCodeLoader.INSTANCE;
|
||||
}
|
||||
return JavaInputPlugin.loadClassFiles(convert.getFiles(), convert);
|
||||
JadxPluginRuntimeData javaInput = context.plugins().getProviding("java-input");
|
||||
context.addCodeInput(inputs -> {
|
||||
RaungConvert convert = new RaungConvert();
|
||||
if (!convert.execute(inputs)) {
|
||||
return EmptyCodeLoader.INSTANCE;
|
||||
}
|
||||
return javaInput.loadCodeFiles(convert.getFiles(), convert);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+10
-18
@@ -1,19 +1,13 @@
|
||||
package jadx.plugins.input.smali;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import jadx.api.plugins.JadxPlugin;
|
||||
import jadx.api.plugins.JadxPluginContext;
|
||||
import jadx.api.plugins.JadxPluginInfo;
|
||||
import jadx.api.plugins.input.ICodeLoader;
|
||||
import jadx.api.plugins.input.JadxCodeInput;
|
||||
import jadx.api.plugins.data.JadxPluginRuntimeData;
|
||||
import jadx.api.plugins.input.data.impl.EmptyCodeLoader;
|
||||
import jadx.plugins.input.dex.DexInputPlugin;
|
||||
|
||||
public class SmaliInputPlugin implements JadxPlugin, JadxCodeInput {
|
||||
|
||||
private final DexInputPlugin dexInput = new DexInputPlugin();
|
||||
public class SmaliInputPlugin implements JadxPlugin {
|
||||
|
||||
@Override
|
||||
public JadxPluginInfo getPluginInfo() {
|
||||
@@ -22,15 +16,13 @@ public class SmaliInputPlugin implements JadxPlugin, JadxCodeInput {
|
||||
|
||||
@Override
|
||||
public void init(JadxPluginContext context) {
|
||||
context.addCodeInput(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ICodeLoader loadFiles(List<Path> input) {
|
||||
SmaliConvert convert = new SmaliConvert();
|
||||
if (!convert.execute(input)) {
|
||||
return EmptyCodeLoader.INSTANCE;
|
||||
}
|
||||
return dexInput.loadFiles(convert.getDexFiles(), convert);
|
||||
JadxPluginRuntimeData dexInput = context.plugins().getById(DexInputPlugin.PLUGIN_ID);
|
||||
context.addCodeInput(input -> {
|
||||
SmaliConvert convert = new SmaliConvert();
|
||||
if (!convert.execute(input)) {
|
||||
return EmptyCodeLoader.INSTANCE;
|
||||
}
|
||||
return dexInput.loadCodeFiles(convert.getDexFiles(), convert);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ import jadx.plugins.input.dex.DexInputPlugin
|
||||
class XapkInputPlugin : JadxPlugin {
|
||||
private val codeInput = XapkCustomCodeInput(this)
|
||||
private val resourcesLoader = XapkCustomResourcesLoader()
|
||||
internal var dexInputPlugin = DexInputPlugin()
|
||||
internal lateinit var dexInputPlugin: DexInputPlugin
|
||||
|
||||
override fun getPluginInfo() = JadxPluginInfo(
|
||||
"xapk-input",
|
||||
@@ -17,6 +17,7 @@ class XapkInputPlugin : JadxPlugin {
|
||||
)
|
||||
|
||||
override fun init(context: JadxPluginContext) {
|
||||
dexInputPlugin = context.plugins().getInstance(DexInputPlugin::class.java)
|
||||
context.addCodeInput(codeInput)
|
||||
context.decompiler.addCustomResourcesLoader(resourcesLoader)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user