Commit 4e6c5cb2 authored by Skylot's avatar Skylot

core: inline anonymous classes with arguments

parent a9c0185b
......@@ -29,6 +29,7 @@ import jadx.core.dex.instructions.args.InsnWrapArg;
import jadx.core.dex.instructions.args.LiteralArg;
import jadx.core.dex.instructions.args.Named;
import jadx.core.dex.instructions.args.RegisterArg;
import jadx.core.dex.instructions.args.SSAVar;
import jadx.core.dex.instructions.mods.ConstructorInsn;
import jadx.core.dex.instructions.mods.TernaryInsn;
import jadx.core.dex.nodes.ClassNode;
......@@ -123,6 +124,9 @@ public class InsnGen {
}
public void declareVar(CodeWriter code, RegisterArg arg) {
if (arg.getSVar().contains(AFlag.FINAL)) {
code.add("final ");
}
useType(code, arg.getType());
code.add(' ');
code.add(mgen.getNameGen().assignArg(arg));
......@@ -144,16 +148,19 @@ public class InsnGen {
if (fieldNode != null) {
FieldReplaceAttr replace = fieldNode.get(AType.FIELD_REPLACE);
if (replace != null) {
FieldInfo info = replace.getFieldInfo();
if (replace.isOuterClass()) {
useClass(code, info.getDeclClass());
code.add(".this");
switch (replace.getReplaceType()) {
case CLASS_INSTANCE:
useClass(code, replace.getClsRef());
code.add(".this");
break;
case VAR:
addArg(code, replace.getVarRef());
break;
}
return;
}
}
addArgDot(code, arg);
fieldNode = mth.dex().resolveField(field);
if (fieldNode != null) {
code.attachAnnotation(fieldNode);
}
......@@ -531,30 +538,7 @@ public class InsnGen {
throws CodegenException {
ClassNode cls = mth.dex().resolveClass(insn.getClassType());
if (cls != null && cls.contains(AFlag.ANONYMOUS_CLASS) && !fallback) {
// anonymous class construction
ArgType parent;
if (cls.getInterfaces().size() == 1) {
parent = cls.getInterfaces().get(0);
} else {
parent = cls.getSuperClass();
}
cls.add(AFlag.DONT_GENERATE);
MethodNode defCtr = cls.getDefaultConstructor();
if (defCtr != null) {
if (RegionUtils.notEmpty(defCtr.getRegion())) {
defCtr.add(AFlag.ANONYMOUS_CONSTRUCTOR);
} else {
defCtr.add(AFlag.DONT_GENERATE);
}
}
code.add("new ");
if (parent == null) {
code.add("Object");
} else {
useClass(code, parent);
}
code.add("() ");
new ClassGen(cls, mgen.getClassGen().getParentGen()).addClassBody(code);
inlineAnonymousConstr(code, cls, insn);
return;
}
if (insn.isSelf()) {
......@@ -568,7 +552,37 @@ public class InsnGen {
code.add("new ");
useClass(code, insn.getClassType());
}
generateMethodArguments(code, insn, 0, mth.dex().resolveMethod(insn.getCallMth()));
MethodNode callMth = mth.dex().resolveMethod(insn.getCallMth());
generateMethodArguments(code, insn, 0, callMth);
}
private void inlineAnonymousConstr(CodeWriter code, ClassNode cls, ConstructorInsn insn) throws CodegenException {
// anonymous class construction
ArgType parent;
if (cls.getInterfaces().size() == 1) {
parent = cls.getInterfaces().get(0);
} else {
parent = cls.getSuperClass();
}
cls.add(AFlag.DONT_GENERATE);
MethodNode defCtr = cls.getDefaultConstructor();
if (defCtr != null) {
if (RegionUtils.notEmpty(defCtr.getRegion())) {
defCtr.add(AFlag.ANONYMOUS_CONSTRUCTOR);
} else {
defCtr.add(AFlag.DONT_GENERATE);
}
}
code.add("new ");
if (parent == null) {
code.add("Object");
} else {
useClass(code, parent);
}
MethodNode callMth = mth.dex().resolveMethod(insn.getCallMth());
generateMethodArguments(code, insn, 0, callMth);
code.add(' ');
new ClassGen(cls, mgen.getClassGen().getParentGen()).addClassBody(code);
}
private void makeInvoke(InvokeNode insn, CodeWriter code) throws CodegenException {
......@@ -631,6 +645,12 @@ public class InsnGen {
boolean overloaded = callMth != null && callMth.isArgsOverload();
for (int i = k; i < argsCount; i++) {
InsnArg arg = insn.getArg(i);
if (arg.isRegister()) {
SSAVar sVar = ((RegisterArg) arg).getSVar();
if (sVar != null && sVar.contains(AFlag.SKIP_ARG)) {
continue;
}
}
boolean cast = overloaded && processOverloadedArg(code, callMth, arg, i - startArgNum);
if (!cast && i == argsCount - 1 && processVarArg(code, callMth, arg)) {
continue;
......
......@@ -8,6 +8,7 @@ import jadx.core.dex.info.AccessInfo;
import jadx.core.dex.instructions.InsnType;
import jadx.core.dex.instructions.args.ArgType;
import jadx.core.dex.instructions.args.RegisterArg;
import jadx.core.dex.instructions.args.SSAVar;
import jadx.core.dex.nodes.InsnNode;
import jadx.core.dex.nodes.MethodNode;
import jadx.core.dex.trycatch.CatchAttr;
......@@ -126,6 +127,10 @@ public class MethodGen {
if (paramsAnnotation != null) {
annotationGen.addForParameter(argsCode, paramsAnnotation, i);
}
SSAVar argSVar = arg.getSVar();
if (argSVar!= null && argSVar.contains(AFlag.FINAL)) {
argsCode.add("final ");
}
if (!it.hasNext() && mth.getAccessFlags().isVarArgs()) {
// change last array argument to varargs
ArgType type = arg.getType();
......
......@@ -8,6 +8,7 @@ public enum AFlag {
LOOP_END,
SYNTHETIC,
FINAL, // SSAVar attribute for make var final
RETURN, // block contains only return instruction
ORIG_RETURN,
......@@ -22,6 +23,7 @@ public enum AFlag {
REMOVE,
SKIP_FIRST_ARG,
SKIP_ARG, // skip argument in invoke call
ANONYMOUS_CONSTRUCTOR,
ANONYMOUS_CLASS,
......
......@@ -2,24 +2,39 @@ package jadx.core.dex.attributes.nodes;
import jadx.core.dex.attributes.AType;
import jadx.core.dex.attributes.IAttribute;
import jadx.core.dex.info.FieldInfo;
import jadx.core.dex.info.ClassInfo;
import jadx.core.dex.instructions.args.InsnArg;
public class FieldReplaceAttr implements IAttribute {
private final FieldInfo fieldInfo;
private final boolean isOuterClass;
public enum ReplaceWith {
CLASS_INSTANCE,
VAR
}
private final ReplaceWith replaceType;
private final Object replaceObj;
public FieldReplaceAttr(ClassInfo cls) {
this.replaceType = ReplaceWith.CLASS_INSTANCE;
this.replaceObj = cls;
}
public FieldReplaceAttr(InsnArg reg) {
this.replaceType = ReplaceWith.VAR;
this.replaceObj = reg;
}
public FieldReplaceAttr(FieldInfo fieldInfo, boolean isOuterClass) {
this.fieldInfo = fieldInfo;
this.isOuterClass = isOuterClass;
public ReplaceWith getReplaceType() {
return replaceType;
}
public FieldInfo getFieldInfo() {
return fieldInfo;
public ClassInfo getClsRef() {
return (ClassInfo) replaceObj;
}
public boolean isOuterClass() {
return isOuterClass;
public InsnArg getVarRef() {
return (InsnArg) replaceObj;
}
@Override
......@@ -29,6 +44,6 @@ public class FieldReplaceAttr implements IAttribute {
@Override
public String toString() {
return "REPLACE: " + fieldInfo;
return "REPLACE: " + replaceType + " " + replaceObj;
}
}
package jadx.core.dex.instructions.args;
import jadx.core.dex.attributes.AttrNode;
import jadx.core.dex.instructions.PhiInsn;
import java.util.ArrayList;
......@@ -8,7 +9,7 @@ import java.util.List;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class SSAVar {
public class SSAVar extends AttrNode {
private final int regNum;
private final int version;
......
......@@ -76,8 +76,7 @@ public class ClassModifier extends AbstractVisitor {
}
}
if (found != 0) {
FieldInfo replace = FieldInfo.from(cls.dex(), parentClass, "this", parentClass.getType());
field.addAttr(new FieldReplaceAttr(replace, true));
field.addAttr(new FieldReplaceAttr(parentClass));
field.add(AFlag.DONT_GENERATE);
}
}
......
......@@ -210,7 +210,9 @@ public class CodeShrinker extends AbstractVisitor {
// }
SSAVar sVar = arg.getSVar();
// allow inline only one use arg or 'this'
if (sVar == null || sVar.getVariableUseCount() != 1 && !arg.isThis()) {
if (sVar == null
|| sVar.getVariableUseCount() != 1 && !arg.isThis()
|| sVar.contains(AFlag.DONT_INLINE)) {
continue;
}
InsnNode assignInsn = sVar.getAssign().getParentInsn();
......
......@@ -18,6 +18,7 @@ public class DebugInfoVisitor extends AbstractVisitor {
DebugInfoParser debugInfoParser = new DebugInfoParser(mth, debugOffset, insnArr);
debugInfoParser.process();
// set method source line from first instruction
if (insnArr.length != 0) {
for (InsnNode insn : insnArr) {
if (insn != null) {
......@@ -30,7 +31,7 @@ public class DebugInfoVisitor extends AbstractVisitor {
}
}
if (!mth.getReturnType().equals(ArgType.VOID)) {
// fix debug for splitter 'return' instructions
// fix debug info for splitter 'return' instructions
for (BlockNode exit : mth.getExitBlocks()) {
InsnNode ret = BlockUtils.getLastInsn(exit);
if (ret == null) {
......
......@@ -23,8 +23,13 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class DotGraphVisitor extends AbstractVisitor {
private static final Logger LOG = LoggerFactory.getLogger(DotGraphVisitor.class);
private static final String NL = "\\l";
private static final boolean PRINT_DOMINATORS = false;
......@@ -52,6 +57,8 @@ public class DotGraphVisitor extends AbstractVisitor {
this.dir = outDir;
this.useRegions = useRegions;
this.rawInsn = rawInsn;
LOG.debug("DOT {}{}graph dump dir: {}",
useRegions ? "regions " : "", rawInsn ? "raw " : "", outDir.getAbsolutePath());
}
@Override
......
......@@ -2,7 +2,11 @@ package jadx.core.dex.visitors;
import jadx.core.codegen.TypeGen;
import jadx.core.deobf.NameMapper;
import jadx.core.dex.attributes.AFlag;
import jadx.core.dex.attributes.AType;
import jadx.core.dex.attributes.nodes.FieldReplaceAttr;
import jadx.core.dex.info.ClassInfo;
import jadx.core.dex.info.FieldInfo;
import jadx.core.dex.info.MethodInfo;
import jadx.core.dex.instructions.ArithNode;
import jadx.core.dex.instructions.ConstClassNode;
......@@ -34,7 +38,10 @@ import jadx.core.utils.InstructionRemover;
import jadx.core.utils.exceptions.JadxRuntimeException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
......@@ -43,6 +50,11 @@ import org.slf4j.LoggerFactory;
* Visitor for modify method instructions
* (remove, replace, process exception handlers)
*/
@JadxVisitor(
name = "ModVisitor",
desc = "Modify method instructions",
runBefore = CodeShrinker.class
)
public class ModVisitor extends AbstractVisitor {
private static final Logger LOG = LoggerFactory.getLogger(ModVisitor.class);
......@@ -197,7 +209,6 @@ public class ModVisitor extends AbstractVisitor {
remover.add(insn);
return;
}
replaceInsn(block, insnNumber, co);
if (co.isNewInstance()) {
InsnNode newInstInsn = removeAssignChain(instArgAssignInsn, remover, InsnType.NEW_INSTANCE);
if (newInstInsn != null) {
......@@ -217,8 +228,107 @@ public class ModVisitor extends AbstractVisitor {
}
ConstructorInsn replace = processConstructor(mth, co);
if (replace != null) {
replaceInsn(block, insnNumber, replace);
co = replace;
}
replaceInsn(block, insnNumber, co);
processAnonymousConstructor(mth, co);
}
private static void processAnonymousConstructor(MethodNode mth, ConstructorInsn co) {
MethodInfo callMth = co.getCallMth();
MethodNode callMthNode = mth.dex().resolveMethod(callMth);
if (callMthNode == null) {
return;
}
ClassNode classNode = callMthNode.getParentClass();
ClassInfo classInfo = classNode.getClassInfo();
ClassNode parentClass = mth.getParentClass();
if (!classInfo.isInner()
|| !Character.isDigit(classInfo.getShortName().charAt(0))
|| !parentClass.getInnerClasses().contains(classNode)) {
return;
}
if (!classNode.getAccessFlags().isStatic()
&& (callMth.getArgsCount() == 0
|| !callMth.getArgumentsTypes().get(0).equals(parentClass.getClassInfo().getType()))) {
return;
}
// TODO: calculate this constructor and other constructor usage
Map<InsnArg, FieldNode> argsMap = getArgsToFieldsMapping(callMthNode, co);
if (argsMap.isEmpty()) {
return;
}
// all checks passed
classNode.add(AFlag.ANONYMOUS_CLASS);
callMthNode.add(AFlag.DONT_GENERATE);
for (Map.Entry<InsnArg, FieldNode> entry : argsMap.entrySet()) {
FieldNode field = entry.getValue();
if (field == null) {
continue;
}
InsnArg arg = entry.getKey();
field.addAttr(new FieldReplaceAttr(arg));
field.add(AFlag.DONT_GENERATE);
if (arg.isRegister()) {
RegisterArg reg = (RegisterArg) arg;
SSAVar sVar = reg.getSVar();
if (sVar != null) {
sVar.add(AFlag.FINAL);
sVar.add(AFlag.DONT_INLINE);
sVar.add(AFlag.SKIP_ARG);
}
}
}
}
private static Map<InsnArg, FieldNode> getArgsToFieldsMapping(MethodNode callMthNode, ConstructorInsn co) {
Map<InsnArg, FieldNode> map = new LinkedHashMap<InsnArg, FieldNode>();
ClassNode parentClass = callMthNode.getParentClass();
List<RegisterArg> argList = callMthNode.getArguments(false);
int startArg = parentClass.getAccessFlags().isStatic() ? 0 : 1;
int argsCount = argList.size();
for (int i = startArg; i < argsCount; i++) {
RegisterArg arg = argList.get(i);
InsnNode useInsn = getParentInsnSkipMove(arg);
if (useInsn == null) {
return Collections.emptyMap();
}
FieldNode fieldNode = null;
if (useInsn.getType() == InsnType.IPUT) {
FieldInfo field = (FieldInfo) ((IndexInsnNode) useInsn).getIndex();
fieldNode = parentClass.searchField(field);
if (fieldNode == null || !fieldNode.getAccessFlags().isSynthetic()) {
return Collections.emptyMap();
}
} else if (useInsn.getType() == InsnType.CONSTRUCTOR) {
ConstructorInsn superConstr = (ConstructorInsn) useInsn;
if (!superConstr.isSuper()) {
return Collections.emptyMap();
}
} else {
return Collections.emptyMap();
}
map.put(co.getArg(i), fieldNode);
}
return map;
}
private static InsnNode getParentInsnSkipMove(RegisterArg arg) {
SSAVar sVar = arg.getSVar();
if (sVar.getUseCount() != 1) {
return null;
}
RegisterArg useArg = sVar.getUseList().get(0);
InsnNode parentInsn = useArg.getParentInsn();
if (parentInsn == null) {
return null;
}
if (parentInsn.getType() == InsnType.MOVE) {
return getParentInsnSkipMove(parentInsn.getResult());
}
return parentInsn;
}
/**
......
......@@ -21,6 +21,11 @@ import java.util.List;
* most of this modification breaks register dependencies,
* so this pass must be just before CodeGen.
*/
@JadxVisitor(
name = "PrepareForCodeGen",
desc = "Prepare instructions for code generation pass",
runAfter = {CodeShrinker.class, ClassModifier.class}
)
public class PrepareForCodeGen extends AbstractVisitor {
@Override
......
......@@ -77,25 +77,7 @@ public class SimplifyVisitor extends AbstractVisitor {
return convertFieldArith(mth, insn);
case CHECK_CAST:
InsnArg castArg = insn.getArg(0);
ArgType argType = castArg.getType();
// Don't removes CHECK_CAST for wrapped INVOKE if invoked method returns different type
if (castArg.isInsnWrap()) {
InsnNode wrapInsn = ((InsnWrapArg) castArg).getWrapInsn();
if (wrapInsn.getType() == InsnType.INVOKE) {
argType = ((InvokeNode) wrapInsn).getCallMth().getReturnType();
}
}
ArgType castToType = (ArgType) ((IndexInsnNode) insn).getIndex();
if (!ArgType.isCastNeeded(mth.dex(), argType, castToType)) {
InsnNode insnNode = new InsnNode(InsnType.MOVE, 1);
insnNode.setOffset(insn.getOffset());
insnNode.setResult(insn.getResult());
insnNode.addArg(castArg);
return insnNode;
}
break;
return processCast(mth, insn);
case MOVE:
InsnArg firstArg = insn.getArg(0);
......@@ -114,6 +96,28 @@ public class SimplifyVisitor extends AbstractVisitor {
return null;
}
private static InsnNode processCast(MethodNode mth, InsnNode insn) {
InsnArg castArg = insn.getArg(0);
ArgType argType = castArg.getType();
// Don't removes CHECK_CAST for wrapped INVOKE if invoked method returns different type
if (castArg.isInsnWrap()) {
InsnNode wrapInsn = ((InsnWrapArg) castArg).getWrapInsn();
if (wrapInsn.getType() == InsnType.INVOKE) {
argType = ((InvokeNode) wrapInsn).getCallMth().getReturnType();
}
}
ArgType castToType = (ArgType) ((IndexInsnNode) insn).getIndex();
if (ArgType.isCastNeeded(mth.dex(), argType, castToType)) {
return null;
}
InsnNode insnNode = new InsnNode(InsnType.MOVE, 1);
insnNode.setOffset(insn.getOffset());
insnNode.setResult(insn.getResult());
insnNode.addArg(castArg);
return insnNode;
}
/**
* Simplify 'cmp' instruction in if condition
*/
......
......@@ -55,7 +55,7 @@ public class ProcessTryCatchRegions extends AbstractRegionVisitor {
}
private static void searchTryCatchDominators(MethodNode mth, Map<BlockNode, TryCatchBlock> tryBlocksMap) {
final Set<TryCatchBlock> tryBlocks = new HashSet<TryCatchBlock>();
Set<TryCatchBlock> tryBlocks = new HashSet<TryCatchBlock>();
// collect all try/catch blocks
for (BlockNode block : mth.getBasicBlocks()) {
CatchAttr c = block.get(AType.CATCH_BLOCK);
......
package jadx.tests.integration.inner;
import jadx.core.dex.nodes.ClassNode;
import jadx.tests.api.IntegrationTest;
import java.util.Random;
import org.junit.Test;
import static jadx.tests.api.utils.JadxMatchers.containsOne;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat;
public class TestAnonymousClass10 extends IntegrationTest {
public static class TestCls {
public A test() {
Random random = new Random();
int a2 = random.nextInt();
int a3 = a2 + 3;
return new A(this, a2, a3, 4, 5, random.nextDouble()) {
@Override
public void m() {
System.out.println(1);
}
};
}
public abstract class A {
public A(TestCls a1, int a2, int a3, int a4, int a5, double a6) {
}
public abstract void m();
}
}
@Test
public void test() {
ClassNode cls = getClassNode(TestCls.class);
String code = cls.getCode().toString();
assertThat(code, containsOne("return new A(this, a2, a2 + 3, 4, 5, random.nextDouble()) {"));
assertThat(code, not(containsString("synthetic")));
}
}
package jadx.tests.integration.inner;
import jadx.core.dex.nodes.ClassNode;
import jadx.tests.api.IntegrationTest;
import org.junit.Test;
import static jadx.tests.api.utils.JadxMatchers.containsOne;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat;
public class TestAnonymousClass6 extends IntegrationTest {
public static class TestCls {
public Runnable test(final double d) {
return new Runnable() {
public void run() {
System.out.println(d);
}
};
}
}
@Test
public void test() {
ClassNode cls = getClassNode(TestCls.class);
String code = cls.getCode().toString();
assertThat(code, containsOne("public Runnable test(final double d) {"));
assertThat(code, containsOne("return new Runnable() {"));
assertThat(code, containsOne("public void run() {"));
assertThat(code, containsOne("System.out.println(d);"));
assertThat(code, not(containsString("synthetic")));
}
}
package jadx.tests.integration.inner;
import jadx.core.dex.nodes.ClassNode;
import jadx.tests.api.IntegrationTest;
import org.junit.Test;
import static jadx.tests.api.utils.JadxMatchers.containsOne;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat;
public class TestAnonymousClass7 extends IntegrationTest {
public static class TestCls {
public static Runnable test(final double d) {
return new Runnable() {
public void run() {
System.out.println(d);
}
};
}
}
@Test
public void test() {
ClassNode cls = getClassNode(TestCls.class);
String code = cls.getCode().toString();
assertThat(code, containsOne("public static Runnable test(final double d) {"));
assertThat(code, containsOne("return new Runnable() {"));
assertThat(code, containsOne("public void run() {"));
assertThat(code, containsOne("System.out.println(d);"));
assertThat(code, not(containsString("synthetic")));
}
}
package jadx.tests.integration.inner;
import jadx.core.dex.nodes.ClassNode;
import jadx.tests.api.IntegrationTest;
import org.junit.Test;
import static jadx.tests.api.utils.JadxMatchers.containsOne;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat;
public class TestAnonymousClass8 extends IntegrationTest {
public static class TestCls {
public final double d = Math.abs(4);
public Runnable test() {
return new Runnable() {
public void run() {
System.out.println(d);
}
};
}
}
@Test
public void test() {
ClassNode cls = getClassNode(TestCls.class);
String code = cls.getCode().toString();
assertThat(code, containsOne("public Runnable test() {"));
assertThat(code, containsOne("return new Runnable() {"));
assertThat(code, containsOne("public void run() {"));
assertThat(code, containsOne("this.d);"));
assertThat(code, not(containsString("synthetic")));
}
}
package jadx.tests.integration.inner;
import jadx.core.dex.nodes.ClassNode;
import jadx.tests.api.IntegrationTest;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import org.junit.Test;
import static jadx.tests.api.utils.JadxMatchers.containsOne;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertThat;
public class TestAnonymousClass9 extends IntegrationTest {
public static class TestCls {
public Callable<String> c = new Callable<String>() {
@Override
public String call() throws Exception {
return "str";
}
};
public Runnable test() {
return new FutureTask<String>(this.c) {
public void run() {
System.out.println(6);
}
};
}
}
@Test
public void test() {
ClassNode cls = getClassNode(TestCls.class);
String code = cls.getCode().toString();
assertThat(code, containsOne("c = new Callable<String>() {"));
assertThat(code, containsOne("return new FutureTask<String>(this.c) {"));
assertThat(code, not(containsString("synthetic")));
}
}
......@@ -17,7 +17,6 @@ public class TestConstructor extends SmaliTest {
disableCompilation();
ClassNode cls = getClassNodeFromSmali("TestConstructor");
String code = cls.getCode().toString();
System.out.println(code);
assertThat(code, containsOne("new SomeObject(arg3);"));
assertThat(code, not(containsString("= someObject")));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment