KT-45777: Parse classes only once with ASM
to get both constants and inline functions. Also add BasicClassInfo to ClassFileWithContents to simplify the code.
This commit is contained in:
committed by
nataliya.valtman
parent
9a995af0df
commit
9b96dbe2d2
@@ -37,6 +37,7 @@ import org.jetbrains.kotlin.name.Name
|
|||||||
import org.jetbrains.kotlin.resolve.jvm.AsmTypes
|
import org.jetbrains.kotlin.resolve.jvm.AsmTypes
|
||||||
import org.jetbrains.kotlin.resolve.jvm.JvmClassName
|
import org.jetbrains.kotlin.resolve.jvm.JvmClassName
|
||||||
import org.jetbrains.org.objectweb.asm.*
|
import org.jetbrains.org.objectweb.asm.*
|
||||||
|
import org.jetbrains.org.objectweb.asm.ClassReader.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
@@ -646,83 +647,86 @@ class KotlinClassInfo constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createFrom(classId: ClassId, classHeader: KotlinClassHeader, classContents: ByteArray): KotlinClassInfo {
|
fun createFrom(classId: ClassId, classHeader: KotlinClassHeader, classContents: ByteArray): KotlinClassInfo {
|
||||||
|
val constantsAndInlineFunctions = getConstantsAndInlineFunctions(classHeader, classContents)
|
||||||
|
|
||||||
return KotlinClassInfo(
|
return KotlinClassInfo(
|
||||||
classId,
|
classId,
|
||||||
classHeader.kind,
|
classHeader.kind,
|
||||||
classHeader.data ?: emptyArray(),
|
classHeader.data ?: emptyArray(),
|
||||||
classHeader.strings ?: emptyArray(),
|
classHeader.strings ?: emptyArray(),
|
||||||
classHeader.multifileClassName,
|
classHeader.multifileClassName,
|
||||||
getConstantsMap(classContents),
|
constantsMap = constantsAndInlineFunctions.first,
|
||||||
getInlineFunctionsMap(classHeader, classContents)
|
inlineFunctionsMap = constantsAndInlineFunctions.second
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getConstantsMap(bytes: ByteArray): LinkedHashMap<String, Any> {
|
/** Parses the class file only once to get both constants and inline functions. */
|
||||||
val result = LinkedHashMap<String, Any>()
|
private fun getConstantsAndInlineFunctions(
|
||||||
|
classHeader: KotlinClassHeader,
|
||||||
|
classContents: ByteArray
|
||||||
|
): Pair<LinkedHashMap<String, Any>, LinkedHashMap<String, Long>> {
|
||||||
|
val constantsClassVisitor = ConstantsClassVisitor()
|
||||||
|
val inlineFunctionNames = inlineFunctionsJvmNames(classHeader)
|
||||||
|
|
||||||
ClassReader(bytes).accept(object : ClassVisitor(Opcodes.API_VERSION) {
|
return if (inlineFunctionNames.isEmpty()) {
|
||||||
override fun visitField(access: Int, name: String, desc: String, signature: String?, value: Any?): FieldVisitor? {
|
ClassReader(classContents).accept(constantsClassVisitor, SKIP_CODE or SKIP_DEBUG or SKIP_FRAMES)
|
||||||
if (access and Opcodes.ACC_PRIVATE == Opcodes.ACC_PRIVATE) return null
|
Pair(constantsClassVisitor.getResult(), LinkedHashMap())
|
||||||
|
} else {
|
||||||
val staticFinal = Opcodes.ACC_STATIC or Opcodes.ACC_FINAL
|
val inlineFunctionsClassVisitor = InlineFunctionsClassVisitor(inlineFunctionNames, constantsClassVisitor)
|
||||||
if (value != null && access and staticFinal == staticFinal) {
|
ClassReader(classContents).accept(inlineFunctionsClassVisitor, 0)
|
||||||
result[name] = value
|
Pair(constantsClassVisitor.getResult(), inlineFunctionsClassVisitor.getResult())
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getInlineFunctionsMap(header: KotlinClassHeader, bytes: ByteArray): LinkedHashMap<String, Long> {
|
private class ConstantsClassVisitor : ClassVisitor(Opcodes.API_VERSION) {
|
||||||
val inlineFunctions = inlineFunctionsJvmNames(header)
|
private val result = LinkedHashMap<String, Any>()
|
||||||
if (inlineFunctions.isEmpty()) return LinkedHashMap()
|
|
||||||
|
|
||||||
val result = LinkedHashMap<String, Long>()
|
override fun visitField(access: Int, name: String, desc: String, signature: String?, value: Any?): FieldVisitor? {
|
||||||
var dummyVersion: Int = -1
|
if (access and Opcodes.ACC_PRIVATE == Opcodes.ACC_PRIVATE) return null
|
||||||
ClassReader(bytes).accept(object : ClassVisitor(Opcodes.API_VERSION) {
|
|
||||||
|
|
||||||
override fun visit(
|
val staticFinal = Opcodes.ACC_STATIC or Opcodes.ACC_FINAL
|
||||||
version: Int,
|
if (value != null && access and staticFinal == staticFinal) {
|
||||||
access: Int,
|
result[name] = value
|
||||||
name: String?,
|
|
||||||
signature: String?,
|
|
||||||
superName: String?,
|
|
||||||
interfaces: Array<out String>?
|
|
||||||
) {
|
|
||||||
super.visit(version, access, name, signature, superName, interfaces)
|
|
||||||
dummyVersion = version
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override fun visitMethod(
|
fun getResult() = result
|
||||||
access: Int,
|
}
|
||||||
name: String,
|
|
||||||
desc: String,
|
|
||||||
signature: String?,
|
|
||||||
exceptions: Array<out String>?
|
|
||||||
): MethodVisitor? {
|
|
||||||
if (access and Opcodes.ACC_PRIVATE == Opcodes.ACC_PRIVATE) return null
|
|
||||||
|
|
||||||
val dummyClassWriter = ClassWriter(0)
|
private class InlineFunctionsClassVisitor(
|
||||||
dummyClassWriter.visit(dummyVersion, 0, "dummy", null, AsmTypes.OBJECT_TYPE.internalName, null)
|
private val inlineFunctionNames: Set<String>,
|
||||||
|
cv: ClassVisitor // Note: cv must not override the visitMethod (it will not be called with the current implementation below)
|
||||||
|
) : ClassVisitor(Opcodes.API_VERSION, cv) {
|
||||||
|
|
||||||
return object : MethodVisitor(Opcodes.API_VERSION, dummyClassWriter.visitMethod(0, name, desc, null, exceptions)) {
|
private val result = LinkedHashMap<String, Long>()
|
||||||
override fun visitEnd() {
|
private var dummyVersion: Int = -1
|
||||||
val jvmName = name + desc
|
|
||||||
if (jvmName !in inlineFunctions) return
|
|
||||||
|
|
||||||
val dummyBytes = dummyClassWriter.toByteArray()!!
|
override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>?) {
|
||||||
|
super.visit(version, access, name, signature, superName, interfaces)
|
||||||
|
dummyVersion = version
|
||||||
|
}
|
||||||
|
|
||||||
val hash = dummyBytes.md5()
|
override fun visitMethod(access: Int, name: String, desc: String, signature: String?, exceptions: Array<out String>?): MethodVisitor? {
|
||||||
result[jvmName] = hash
|
if (access and Opcodes.ACC_PRIVATE == Opcodes.ACC_PRIVATE) return null
|
||||||
}
|
|
||||||
|
val dummyClassWriter = ClassWriter(0)
|
||||||
|
dummyClassWriter.visit(dummyVersion, 0, "dummy", null, AsmTypes.OBJECT_TYPE.internalName, null)
|
||||||
|
|
||||||
|
return object : MethodVisitor(Opcodes.API_VERSION, dummyClassWriter.visitMethod(0, name, desc, null, exceptions)) {
|
||||||
|
override fun visitEnd() {
|
||||||
|
val jvmName = name + desc
|
||||||
|
if (jvmName !in inlineFunctionNames) return
|
||||||
|
|
||||||
|
val dummyBytes = dummyClassWriter.toByteArray()!!
|
||||||
|
|
||||||
|
val hash = dummyBytes.md5()
|
||||||
|
result[jvmName] = hash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}, 0)
|
fun getResult() = result
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -12,6 +12,7 @@ import org.jetbrains.kotlin.name.ClassId
|
|||||||
import org.jetbrains.kotlin.resolve.jvm.JvmClassName
|
import org.jetbrains.kotlin.resolve.jvm.JvmClassName
|
||||||
import org.jetbrains.org.objectweb.asm.AnnotationVisitor
|
import org.jetbrains.org.objectweb.asm.AnnotationVisitor
|
||||||
import org.jetbrains.org.objectweb.asm.ClassReader
|
import org.jetbrains.org.objectweb.asm.ClassReader
|
||||||
|
import org.jetbrains.org.objectweb.asm.ClassReader.*
|
||||||
import org.jetbrains.org.objectweb.asm.ClassVisitor
|
import org.jetbrains.org.objectweb.asm.ClassVisitor
|
||||||
import org.jetbrains.org.objectweb.asm.Opcodes
|
import org.jetbrains.org.objectweb.asm.Opcodes
|
||||||
|
|
||||||
@@ -46,10 +47,7 @@ class BasicClassInfo(
|
|||||||
val innerClassesClassVisitor = InnerClassesClassVisitor(kotlinClassHeaderClassVisitor)
|
val innerClassesClassVisitor = InnerClassesClassVisitor(kotlinClassHeaderClassVisitor)
|
||||||
val basicClassInfoVisitor = BasicClassInfoClassVisitor(innerClassesClassVisitor)
|
val basicClassInfoVisitor = BasicClassInfoClassVisitor(innerClassesClassVisitor)
|
||||||
|
|
||||||
ClassReader(classContents).accept(
|
ClassReader(classContents).accept(basicClassInfoVisitor, SKIP_CODE or SKIP_DEBUG or SKIP_FRAMES)
|
||||||
basicClassInfoVisitor,
|
|
||||||
ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES
|
|
||||||
)
|
|
||||||
|
|
||||||
val className = basicClassInfoVisitor.getClassName()
|
val className = basicClassInfoVisitor.getClassName()
|
||||||
val innerClassesInfo = innerClassesClassVisitor.getInnerClassesInfo()
|
val innerClassesInfo = innerClassesClassVisitor.getInnerClassesInfo()
|
||||||
|
|||||||
+6
-2
@@ -29,8 +29,12 @@ class ClassFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Information to locate a .class file, plus their contents. */
|
/** Contains the contents of a [ClassFile] and information extracted from the contents. */
|
||||||
class ClassFileWithContents(
|
class ClassFileWithContents(
|
||||||
val classFile: ClassFile,
|
val classFile: ClassFile,
|
||||||
val contents: ByteArray
|
val contents: ByteArray
|
||||||
)
|
) {
|
||||||
|
val classInfo: BasicClassInfo by lazy {
|
||||||
|
BasicClassInfo.compute(contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-32
@@ -76,18 +76,13 @@ object ClassSnapshotter {
|
|||||||
protoBased: Boolean? = null,
|
protoBased: Boolean? = null,
|
||||||
includeDebugInfoInSnapshot: Boolean? = null
|
includeDebugInfoInSnapshot: Boolean? = null
|
||||||
): List<ClassSnapshot> {
|
): List<ClassSnapshot> {
|
||||||
val classesInfo: List<BasicClassInfo> = classes.map { BasicClassInfo.compute(it.contents) }
|
|
||||||
|
|
||||||
// Find inaccessible classes first, their snapshots will be `InaccessibleClassSnapshot`s.
|
// Find inaccessible classes first, their snapshots will be `InaccessibleClassSnapshot`s.
|
||||||
val inaccessibleClasses: Set<BasicClassInfo> = getInaccessibleClasses(classesInfo).toSet()
|
val classesInfo: List<BasicClassInfo> = classes.map { it.classInfo }
|
||||||
|
val inaccessibleClassesInfo: Set<BasicClassInfo> = getInaccessibleClasses(classesInfo).toSet()
|
||||||
|
|
||||||
// Snapshot the remaining accessible classes
|
// Snapshot the remaining accessible classes
|
||||||
val accessibleClasses: List<ClassFileWithContents> = classes.mapIndexedNotNull { index, clazz ->
|
val accessibleClasses: List<ClassFileWithContents> = classes.filter { it.classInfo !in inaccessibleClassesInfo }
|
||||||
if (classesInfo[index] in inaccessibleClasses) null else clazz
|
val accessibleSnapshots: List<ClassSnapshot> = doSnapshot(accessibleClasses, protoBased, includeDebugInfoInSnapshot)
|
||||||
}
|
|
||||||
val accessibleClassesInfo: List<BasicClassInfo> = classesInfo.filterNot { it in inaccessibleClasses }
|
|
||||||
val accessibleSnapshots: List<ClassSnapshot> =
|
|
||||||
doSnapshot(accessibleClasses, accessibleClassesInfo, protoBased, includeDebugInfoInSnapshot)
|
|
||||||
val accessibleClassSnapshots: Map<ClassFileWithContents, ClassSnapshot> = accessibleClasses.zipToMap(accessibleSnapshots)
|
val accessibleClassSnapshots: Map<ClassFileWithContents, ClassSnapshot> = accessibleClasses.zipToMap(accessibleSnapshots)
|
||||||
|
|
||||||
return classes.map { accessibleClassSnapshots[it] ?: InaccessibleClassSnapshot }
|
return classes.map { accessibleClassSnapshots[it] ?: InaccessibleClassSnapshot }
|
||||||
@@ -95,58 +90,47 @@ object ClassSnapshotter {
|
|||||||
|
|
||||||
private fun doSnapshot(
|
private fun doSnapshot(
|
||||||
classes: List<ClassFileWithContents>,
|
classes: List<ClassFileWithContents>,
|
||||||
classesInfo: List<BasicClassInfo>,
|
|
||||||
protoBased: Boolean? = null,
|
protoBased: Boolean? = null,
|
||||||
includeDebugInfoInSnapshot: Boolean? = null
|
includeDebugInfoInSnapshot: Boolean? = null
|
||||||
): List<ClassSnapshot> {
|
): List<ClassSnapshot> {
|
||||||
// Snapshot Kotlin classes first
|
// Snapshot Kotlin classes first
|
||||||
val kotlinSnapshots: List<KotlinClassSnapshot?> = classes.mapIndexed { index, clazz ->
|
val kotlinSnapshots: List<KotlinClassSnapshot?> = classes.map { clazz ->
|
||||||
trySnapshotKotlinClass(clazz, classesInfo[index])
|
trySnapshotKotlinClass(clazz)
|
||||||
}
|
}
|
||||||
val kotlinClassSnapshots: Map<ClassFileWithContents, KotlinClassSnapshot?> = classes.zipToMap(kotlinSnapshots)
|
val kotlinClassSnapshots: Map<ClassFileWithContents, KotlinClassSnapshot?> = classes.zipToMap(kotlinSnapshots)
|
||||||
|
|
||||||
// Snapshot the remaining Java classes
|
// Snapshot the remaining Java classes
|
||||||
val javaClasses: List<ClassFileWithContents> = classes.filter { kotlinClassSnapshots[it] == null }
|
val javaClasses: List<ClassFileWithContents> = classes.filter { kotlinClassSnapshots[it] == null }
|
||||||
val javaClassesInfo: List<BasicClassInfo> = classesInfo.mapIndexedNotNull { index, classInfo ->
|
val javaSnapshots: List<JavaClassSnapshot> = snapshotJavaClasses(javaClasses, protoBased, includeDebugInfoInSnapshot)
|
||||||
val javaClass = classes[index]
|
|
||||||
if (kotlinClassSnapshots[javaClass] == null) classInfo else null
|
|
||||||
}
|
|
||||||
val javaSnapshots: List<JavaClassSnapshot> =
|
|
||||||
snapshotJavaClasses(javaClasses, javaClassesInfo, protoBased, includeDebugInfoInSnapshot)
|
|
||||||
val javaClassSnapshots: Map<ClassFileWithContents, JavaClassSnapshot> = javaClasses.zipToMap(javaSnapshots)
|
val javaClassSnapshots: Map<ClassFileWithContents, JavaClassSnapshot> = javaClasses.zipToMap(javaSnapshots)
|
||||||
|
|
||||||
return classes.map { kotlinClassSnapshots[it] ?: javaClassSnapshots[it]!! }
|
return classes.map { kotlinClassSnapshots[it] ?: javaClassSnapshots[it]!! }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates [KotlinClassSnapshot] of the given class, or returns `null` if the class is not a Kotlin class. */
|
/** Creates [KotlinClassSnapshot] of the given class, or returns `null` if the class is not a Kotlin class. */
|
||||||
private fun trySnapshotKotlinClass(classFile: ClassFileWithContents, classInfo: BasicClassInfo): KotlinClassSnapshot? {
|
private fun trySnapshotKotlinClass(classFile: ClassFileWithContents): KotlinClassSnapshot? {
|
||||||
return if (classInfo.isKotlinClass) {
|
return if (classFile.classInfo.isKotlinClass) {
|
||||||
val kotlinClassInfo = KotlinClassInfo.createFrom(classInfo.classId, classInfo.kotlinClassHeader!!, classFile.contents)
|
val kotlinClassInfo =
|
||||||
KotlinClassSnapshot(kotlinClassInfo, classInfo.supertypes)
|
KotlinClassInfo.createFrom(classFile.classInfo.classId, classFile.classInfo.kotlinClassHeader!!, classFile.contents)
|
||||||
|
KotlinClassSnapshot(kotlinClassInfo, classFile.classInfo.supertypes)
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates [JavaClassSnapshot]s of the given Java classes. */
|
/** Creates [JavaClassSnapshot]s of the given Java classes. */
|
||||||
private fun snapshotJavaClasses(
|
private fun snapshotJavaClasses(
|
||||||
classes: List<ClassFileWithContents>,
|
classes: List<ClassFileWithContents>,
|
||||||
classesInfo: List<BasicClassInfo>,
|
|
||||||
protoBased: Boolean? = null,
|
protoBased: Boolean? = null,
|
||||||
includeDebugInfoInSnapshot: Boolean? = null
|
includeDebugInfoInSnapshot: Boolean? = null
|
||||||
): List<JavaClassSnapshot> {
|
): List<JavaClassSnapshot> {
|
||||||
return if (protoBased ?: protoBasedDefaultValue) {
|
return if (protoBased ?: protoBasedDefaultValue) {
|
||||||
snapshotJavaClassesProtoBased(classes, classesInfo)
|
snapshotJavaClassesProtoBased(classes)
|
||||||
} else {
|
} else {
|
||||||
classes.mapIndexed { index, clazz ->
|
classes.map { JavaClassSnapshotter.snapshot(it, includeDebugInfoInSnapshot) }
|
||||||
JavaClassSnapshotter.snapshot(clazz.contents, classesInfo[index], includeDebugInfoInSnapshot)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun snapshotJavaClassesProtoBased(
|
private fun snapshotJavaClassesProtoBased(classFilesWithContents: List<ClassFileWithContents>): List<JavaClassSnapshot> {
|
||||||
classFilesWithContents: List<ClassFileWithContents>,
|
val classIds = classFilesWithContents.map { it.classInfo.classId }
|
||||||
classesInfo: List<BasicClassInfo>
|
|
||||||
): List<JavaClassSnapshot> {
|
|
||||||
val classIds = classesInfo.map { it.classId }
|
|
||||||
val classesContents = classFilesWithContents.map { it.contents }
|
val classesContents = classFilesWithContents.map { it.contents }
|
||||||
val descriptors: List<JavaClassDescriptor?> = JavaClassDescriptorCreator.create(classIds, classesContents)
|
val descriptors: List<JavaClassDescriptor?> = JavaClassDescriptorCreator.create(classIds, classesContents)
|
||||||
val snapshots: List<JavaClassSnapshot> = descriptors.mapIndexed { index, descriptor ->
|
val snapshots: List<JavaClassSnapshot> = descriptors.mapIndexed { index, descriptor ->
|
||||||
|
|||||||
+6
-3
@@ -14,7 +14,7 @@ import org.jetbrains.org.objectweb.asm.tree.ClassNode
|
|||||||
/** Computes a [JavaClassSnapshot] of a Java class. */
|
/** Computes a [JavaClassSnapshot] of a Java class. */
|
||||||
object JavaClassSnapshotter {
|
object JavaClassSnapshotter {
|
||||||
|
|
||||||
fun snapshot(classContents: ByteArray, classInfo: BasicClassInfo, includeDebugInfoInSnapshot: Boolean? = null): JavaClassSnapshot {
|
fun snapshot(classFile: ClassFileWithContents, includeDebugInfoInSnapshot: Boolean? = null): JavaClassSnapshot {
|
||||||
// We will extract ABI information from the given class and store it into the `abiClass` variable.
|
// We will extract ABI information from the given class and store it into the `abiClass` variable.
|
||||||
// It is acceptable to collect more info than required, but it is incorrect to collect less info than required.
|
// It is acceptable to collect more info than required, but it is incorrect to collect less info than required.
|
||||||
// There are 2 approaches:
|
// There are 2 approaches:
|
||||||
@@ -29,7 +29,7 @@ object JavaClassSnapshotter {
|
|||||||
// - SKIP_CODE and SKIP_FRAMES are set as method bodies will not be part of the ABI of the class.
|
// - SKIP_CODE and SKIP_FRAMES are set as method bodies will not be part of the ABI of the class.
|
||||||
// - SKIP_DEBUG is not set as it would skip method parameters, which may be used by annotation processors like Room.
|
// - SKIP_DEBUG is not set as it would skip method parameters, which may be used by annotation processors like Room.
|
||||||
// - EXPAND_FRAMES is not needed (and not relevant when SKIP_CODE is set).
|
// - EXPAND_FRAMES is not needed (and not relevant when SKIP_CODE is set).
|
||||||
val classReader = ClassReader(classContents)
|
val classReader = ClassReader(classFile.contents)
|
||||||
classReader.accept(abiClass, ClassReader.SKIP_CODE or ClassReader.SKIP_FRAMES)
|
classReader.accept(abiClass, ClassReader.SKIP_CODE or ClassReader.SKIP_FRAMES)
|
||||||
|
|
||||||
// Then, remove non-ABI info, which includes:
|
// Then, remove non-ABI info, which includes:
|
||||||
@@ -52,7 +52,10 @@ object JavaClassSnapshotter {
|
|||||||
abiClass.methods.clear()
|
abiClass.methods.clear()
|
||||||
val classAbiExcludingMembers = abiClass.let { snapshotJavaElement(it, it.name, includeDebugInfoInSnapshot) }
|
val classAbiExcludingMembers = abiClass.let { snapshotJavaElement(it, it.name, includeDebugInfoInSnapshot) }
|
||||||
|
|
||||||
return RegularJavaClassSnapshot(classInfo.classId, classInfo.supertypes, classAbiExcludingMembers, fieldsAbi, methodsAbi)
|
return RegularJavaClassSnapshot(
|
||||||
|
classFile.classInfo.classId, classFile.classInfo.supertypes,
|
||||||
|
classAbiExcludingMembers, fieldsAbi, methodsAbi
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val gson by lazy {
|
private val gson by lazy {
|
||||||
|
|||||||
Reference in New Issue
Block a user