feat: parse and use Kotlin SourceDebugExtension with SMAP for rename classes and packages (PR #2389)

* feat: parse and use Kotlin SourceDebugExtension for rename classes and package

* fix: fixed typo

* fix: fixed spotless checks

* fix: fixed spotless checks
This commit is contained in:
Yaroslav
2025-01-06 22:16:26 +02:00
committed by GitHub
parent 6889670b11
commit 29d114402d
16 changed files with 513 additions and 0 deletions
+1
View File
@@ -18,6 +18,7 @@ dependencies {
runtimeOnly(project(":jadx-plugins:jadx-smali-input"))
runtimeOnly(project(":jadx-plugins:jadx-rename-mappings"))
runtimeOnly(project(":jadx-plugins:jadx-kotlin-metadata"))
runtimeOnly(project(":jadx-plugins:jadx-kotlin-source-debug-extension"))
runtimeOnly(project(":jadx-plugins:jadx-script:jadx-script-plugin"))
runtimeOnly(project(":jadx-plugins:jadx-xapk-input"))
runtimeOnly(project(":jadx-plugins:jadx-aab-input"))
@@ -0,0 +1,13 @@
plugins {
id("jadx-library")
id("jadx-kotlin")
}
dependencies {
api(project(":jadx-core"))
testImplementation(project.project(":jadx-core").sourceSets.getByName("test").output)
testImplementation("org.apache.commons:commons-lang3:3.17.0")
testRuntimeOnly(project(":jadx-plugins:jadx-smali-input"))
}
@@ -0,0 +1,24 @@
package jadx.plugins.kotlin.smap
import jadx.api.plugins.options.impl.BasePluginOptionsBuilder
import jadx.plugins.kotlin.smap.KotlinSmapPlugin.Companion.PLUGIN_ID
class KotlinSmapOptions : BasePluginOptionsBuilder() {
var isClassAliasSourceDbg: Boolean = true
private set
override fun registerOptions() {
boolOption(CLASS_ALIAS_SOURCE_DBG_OPT)
.description("rename class alias from SourceDebugExtension")
.defaultValue(false)
.setter { isClassAliasSourceDbg = it }
}
fun isClassSourceDbg(): Boolean {
return isClassAliasSourceDbg
}
companion object {
const val CLASS_ALIAS_SOURCE_DBG_OPT = "$PLUGIN_ID.class-alias-source-dbg"
}
}
@@ -0,0 +1,27 @@
package jadx.plugins.kotlin.smap
import jadx.api.plugins.JadxPlugin
import jadx.api.plugins.JadxPluginContext
import jadx.api.plugins.JadxPluginInfo
import jadx.plugins.kotlin.smap.pass.KotlinSourceDebugExtensionPass
class KotlinSmapPlugin : JadxPlugin {
private val options = KotlinSmapOptions()
override fun getPluginInfo(): JadxPluginInfo {
return JadxPluginInfo(PLUGIN_ID, "Kotlin SMAP", "Use kotlin.SourceDebugExtension annotation for rename class alias")
}
override fun init(context: JadxPluginContext) {
context.registerOptions(options)
if (options.isClassSourceDbg()) {
context.addPass(KotlinSourceDebugExtensionPass(options))
}
}
companion object {
const val PLUGIN_ID = "kotlin-smap"
}
}
@@ -0,0 +1,6 @@
package jadx.plugins.kotlin.smap.model
data class ClassAliasRename(
val pkg: String,
val name: String,
)
@@ -0,0 +1,5 @@
package jadx.plugins.kotlin.smap.model
object Constants {
const val KOTLIN_SOURCE_DEBUG_EXTENSION = "Lkotlin/jvm/internal/SourceDebugExtension;"
}
@@ -0,0 +1,78 @@
/*
* Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package jadx.plugins.kotlin.smap.model
import kotlin.math.max
const val KOTLIN_STRATA_NAME = "Kotlin"
const val KOTLIN_DEBUG_STRATA_NAME = "KotlinDebug"
/**
* Represents SMAP as a structure that is contained in `SourceDebugExtension` attribute of a class.
* This structure is immutable, we can only query for a result.
*/
class SMAP(val fileMappings: List<FileMapping>) {
// assuming disjoint line mappings (otherwise binary search can't be used anyway)
private val intervals = fileMappings.flatMap { it.lineMappings }.sortedBy { it.dest }
fun findRange(lineNumber: Int): RangeMapping? {
val index = intervals.binarySearch { if (lineNumber in it) 0 else it.dest - lineNumber }
return if (index < 0) null else intervals[index]
}
companion object {
const val FILE_SECTION = "*F"
const val LINE_SECTION = "*L"
const val STRATA_SECTION = "*S"
const val END = "*E"
}
}
class FileMapping(val name: String, val path: String) {
val lineMappings = arrayListOf<RangeMapping>()
fun toSourceInfo(): SourceInfo =
SourceInfo(
name,
path,
lineMappings.fold(0) { result, mapping -> max(result, mapping.source + mapping.range - 1) },
)
fun mapNewLineNumber(source: Int, currentIndex: Int, callSite: SourcePosition?): Int {
// Save some space in the SMAP by reusing (or extending if it's the last one) the existing range.
// TODO some *other* range may already cover `source`; probably too slow to check them all though.
// Maybe keep the list ordered by `source` and use binary search to locate the closest range on the left?
val mapping = lineMappings.lastOrNull()?.takeIf { it.canReuseFor(source, currentIndex, callSite) }
?: lineMappings.firstOrNull()?.takeIf { it.canReuseFor(source, currentIndex, callSite) }
?: mapNewInterval(source, currentIndex + 1, 1, callSite)
mapping.range = max(mapping.range, source - mapping.source + 1)
return mapping.mapSourceToDest(source)
}
private fun RangeMapping.canReuseFor(newSource: Int, globalMaxDest: Int, newCallSite: SourcePosition?): Boolean =
callSite == newCallSite && (newSource - source) in 0 until range + (if (globalMaxDest in this) 10 else 0)
fun mapNewInterval(source: Int, dest: Int, range: Int, callSite: SourcePosition? = null): RangeMapping =
RangeMapping(source, dest, range, callSite, parent = this).also { lineMappings.add(it) }
}
data class RangeMapping(val source: Int, val dest: Int, var range: Int, val callSite: SourcePosition?, val parent: FileMapping) {
operator fun contains(destLine: Int): Boolean =
dest <= destLine && destLine < dest + range
fun hasMappingForSource(sourceLine: Int): Boolean =
source <= sourceLine && sourceLine < source + range
fun mapDestToSource(destLine: Int): SourcePosition =
SourcePosition(source + (destLine - dest), parent.name, parent.path)
fun mapSourceToDest(sourceLine: Int): Int =
dest + (sourceLine - source)
}
val RangeMapping.toRange: IntRange
get() = dest until dest + range
data class SourcePosition(val line: Int, val file: String, val path: String)
@@ -0,0 +1,7 @@
package jadx.plugins.kotlin.smap.model
data class SourceInfo(
val sourceFileName: String?,
val pathOrCleanFQN: String,
val linesInFile: Int,
)
@@ -0,0 +1,39 @@
package jadx.plugins.kotlin.smap.pass
import jadx.api.plugins.pass.JadxPassInfo
import jadx.api.plugins.pass.impl.OrderedJadxPassInfo
import jadx.api.plugins.pass.types.JadxPreparePass
import jadx.core.dex.attributes.AFlag
import jadx.core.dex.nodes.RootNode
import jadx.plugins.kotlin.smap.KotlinSmapOptions
import jadx.plugins.kotlin.smap.utils.KotlinSmapUtils
class KotlinSourceDebugExtensionPass(
private val options: KotlinSmapOptions,
) : JadxPreparePass {
override fun getInfo(): JadxPassInfo {
return OrderedJadxPassInfo(
"SourceDebugExtensionPrepare",
"Use kotlin.jvm.internal.SourceDebugExtension annotation to rename class & package",
)
.before("RenameVisitor")
}
override fun init(root: RootNode) {
if (options.isClassAliasSourceDbg) {
for (cls in root.classes) {
if (cls.contains(AFlag.DONT_RENAME)) {
continue
}
// rename class & package
val kotlinCls = KotlinSmapUtils.getClassAlias(cls)
if (kotlinCls != null) {
cls.rename(kotlinCls.name)
cls.packageNode.rename(kotlinCls.pkg)
}
}
}
}
}
@@ -0,0 +1,24 @@
@file:Suppress("UNCHECKED_CAST")
package jadx.plugins.kotlin.smap.utils
import jadx.api.plugins.input.data.annotations.EncodedType
import jadx.api.plugins.input.data.annotations.EncodedValue
import jadx.api.plugins.input.data.annotations.IAnnotation
import jadx.core.dex.nodes.ClassNode
import jadx.plugins.kotlin.smap.model.Constants
import jadx.plugins.kotlin.smap.model.SMAP
fun ClassNode.getSourceDebugExtension(): SMAP? {
val annotation: IAnnotation? = getAnnotation(Constants.KOTLIN_SOURCE_DEBUG_EXTENSION)
return annotation?.run {
val smapParser = SMAPParser.parseOrNull(getParamsAsList("value")?.get(0)?.value.toString())
return smapParser
}
}
private fun IAnnotation.getParamsAsList(paramName: String): List<EncodedValue>? {
val encodedValue = values[paramName]
?.takeIf { it.type == EncodedType.ENCODED_ARRAY && it.value is List<*> }
return encodedValue?.value?.let { it as List<EncodedValue> }
}
@@ -0,0 +1,72 @@
package jadx.plugins.kotlin.smap.utils
import jadx.core.deobf.NameMapper
import jadx.core.dex.attributes.nodes.RenameReasonAttr
import jadx.core.dex.nodes.ClassNode
import jadx.core.utils.Utils
import jadx.plugins.kotlin.smap.model.ClassAliasRename
import jadx.plugins.kotlin.smap.model.SMAP
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlin.jvm.java
object KotlinSmapUtils {
val LOG: Logger = LoggerFactory.getLogger(KotlinSmapUtils::class.java)
@JvmStatic
fun getClassAlias(cls: ClassNode): ClassAliasRename? {
val annotation = cls.getSourceDebugExtension() ?: return null
return getClassAlias(cls, annotation)
}
private fun getClassAlias(cls: ClassNode, annotation: SMAP): ClassAliasRename? {
val firstValue = annotation.fileMappings[0].path.replace("/", ".")
try {
val clsName = firstValue.trim()
.takeUnless(String::isEmpty)
?.let(Utils::cleanObjectName)
?: return null
val alias = splitAndCheckClsName(cls, clsName)
if (alias != null) {
RenameReasonAttr.forNode(cls).append("from SourceDebugExtension")
return alias
}
} catch (e: Exception) {
LOG.error("Failed to parse SourceDebugExtension", e)
}
return null
}
// Don't use ClassInfo facility to not pollute class into cache
private fun splitAndCheckClsName(originCls: ClassNode, fullClsName: String): ClassAliasRename? {
if (!NameMapper.isValidFullIdentifier(fullClsName)) {
return null
}
val pkg: String
val name: String
val dot = fullClsName.lastIndexOf('.')
if (dot == -1) {
pkg = ""
name = fullClsName
} else {
pkg = fullClsName.substring(0, dot)
name = fullClsName.substring(dot + 1)
}
val originClsInfo = originCls.classInfo
val originName = originClsInfo.shortName
if (originName == name || name.contains("$") ||
!NameMapper.isValidIdentifier(name) || pkg.startsWith("java.")
) {
return null
}
val newClsNode = originCls.root().resolveClass(fullClsName)
return if (newClsNode != null) {
// class with alias name already exist
null
} else {
ClassAliasRename(pkg, name)
}
}
}
@@ -0,0 +1,117 @@
/*
* Copyright 2010-2015 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package jadx.plugins.kotlin.smap.utils
import jadx.plugins.kotlin.smap.model.FileMapping
import jadx.plugins.kotlin.smap.model.KOTLIN_DEBUG_STRATA_NAME
import jadx.plugins.kotlin.smap.model.KOTLIN_STRATA_NAME
import jadx.plugins.kotlin.smap.model.SMAP
object SMAPParser {
fun parseOrNull(mappingInfo: String): SMAP? =
if (mappingInfo.isNotEmpty()) {
parseStratum(mappingInfo, KOTLIN_STRATA_NAME, parseStratum(mappingInfo, KOTLIN_DEBUG_STRATA_NAME, null))
} else {
null
}
private class SMAPTokenizer(private val text: String, private val headerString: String) : Iterator<String> {
private var pos = 0
private var currentLine: String? = null
init {
advance()
while (currentLine != null && currentLine != headerString) {
advance()
}
if (currentLine == headerString) {
advance()
}
}
private fun advance() {
if (pos >= text.length) {
currentLine = null
return
}
val fromPos = pos
while (pos < text.length && text[pos] != '\n' && text[pos] != '\r') pos++
currentLine = text.substring(fromPos, pos)
pos++
}
override fun hasNext(): Boolean {
return currentLine != null
}
override fun next(): String {
val res = currentLine ?: throw NoSuchElementException()
advance()
return res
}
}
private fun parseStratum(mappingInfo: String, stratum: String, callSites: SMAP?): SMAP? {
val fileMappings = linkedMapOf<Int, FileMapping>()
val iterator = SMAPTokenizer(mappingInfo, "${SMAP.STRATA_SECTION} $stratum")
// JSR-045 allows the line section to come before the file section, but we don't generate SMAPs like this.
if (!iterator.hasNext() || iterator.next() != SMAP.FILE_SECTION) return null
for (line in iterator) {
when {
line == SMAP.LINE_SECTION -> break
line == SMAP.FILE_SECTION || line == SMAP.END || line.startsWith(SMAP.STRATA_SECTION) -> return null
}
val indexAndFileInternalName = if (line.startsWith("+ ")) line.substring(2) else line
val fileIndex = indexAndFileInternalName.substringBefore(' ').toInt()
val fileName = indexAndFileInternalName.substringAfter(' ')
val path = if (line.startsWith("+ ")) iterator.next() else fileName
fileMappings[fileIndex] = FileMapping(fileName, path)
}
for (line in iterator) {
when {
line == SMAP.LINE_SECTION || line == SMAP.FILE_SECTION -> return null
line == SMAP.END || line.startsWith(SMAP.STRATA_SECTION) -> break
}
// <source>#<file>,<sourceRange>:<dest>,<destMultiplier>
val fileSeparator = line.indexOf('#')
if (fileSeparator < 0) return null
val destSeparator = line.indexOf(':', fileSeparator)
if (destSeparator < 0) return null
val sourceRangeSeparator = line.indexOf(',').let { if (it !in fileSeparator..destSeparator) destSeparator else it }
val destMultiplierSeparator = line.indexOf(',', destSeparator).let { if (it < 0) line.length else it }
val file = fileMappings[line.substring(fileSeparator + 1, sourceRangeSeparator).toInt()] ?: return null
val source = line.substring(0, fileSeparator).toInt()
val dest = line.substring(destSeparator + 1, destMultiplierSeparator).toInt()
val range = when {
// These two fields have a different meaning, but for compatibility we treat them the same. See `SMAPBuilder`.
destMultiplierSeparator != line.length -> line.substring(destMultiplierSeparator + 1).toInt()
sourceRangeSeparator != destSeparator -> line.substring(sourceRangeSeparator + 1, destSeparator).toInt()
else -> 1
}
// Here we assume that each range in `Kotlin` is entirely within at most one range in `KotlinDebug`.
file.mapNewInterval(source, dest, range, callSites?.findRange(dest)?.let { it.mapDestToSource(it.dest) })
}
return SMAP(fileMappings.values.toList())
}
}
@@ -0,0 +1 @@
jadx.plugins.kotlin.smap.KotlinSmapPlugin
@@ -0,0 +1,34 @@
package jadx.plugins.kotlin.metadata.tests
import jadx.plugins.kotlin.smap.KotlinSmapOptions.Companion.CLASS_ALIAS_SOURCE_DBG_OPT
import jadx.tests.api.SmaliTest
import jadx.tests.api.utils.assertj.JadxAssertions.assertThat
import jadx.tests.api.utils.assertj.JadxCodeAssertions
import org.junit.jupiter.api.Test
class TestSourceDebugExtension : SmaliTest() {
@Test
fun testRenameClass() {
setupArgs {
this[CLASS_ALIAS_SOURCE_DBG_OPT] = true
}
assertThatClass()
.containsOne("androidx.compose.ui")
.containsOne("public final class ActualKt")
.countString(1, "reason: from SourceDebugExtension")
}
private fun setupArgs(builder: MutableMap<String, Boolean>.() -> Unit = {}) {
val allOff = mutableMapOf(
CLASS_ALIAS_SOURCE_DBG_OPT to false,
)
args.pluginOptions = allOff.apply(builder).mapValues {
if (it.value) "yes" else "no"
}
}
private fun assertThatClass(): JadxCodeAssertions =
assertThat(getClassNodeFromSmaliFiles("deobf", "TestKotlinSourceDebugExtension", "C6"))
.code()
}
@@ -0,0 +1,64 @@
.class public final Ldeobf/C6;
.super Ljava/lang/Object;
.source "SourceFile"
# annotations
.annotation runtime Lkotlin/Metadata;
d1 = {
"\u0000\u000e\n\u0002\u0010\u0000\n\u0002\u0008\u0002\n\u0002\u0010\u000b\n\u0000\u001a\u0018\u0010\u0001\u001a\u00020\u00032\u0006\u0010\u0001\u001a\u00020\u00002\u0006\u0010\u0002\u001a\u00020\u0000H\u0000\u00a8\u0006\u0004"
}
d2 = {
"",
"a",
"b",
"",
"ui_release"
}
k = 0x2
mv = {
0x1,
0x8,
0x0
}
.end annotation
.annotation build Lkotlin/jvm/internal/SourceDebugExtension;
value = {
"SMAP\nActual.kt\nKotlin\n*S Kotlin\n*F\n+ 1 Actual.kt\nandroidx/compose/ui/ActualKt\n+ 2 _Arrays.kt\nkotlin/collections/ArraysKt___ArraysKt\n+ 3 ListUtils.kt\nandroidx/compose/ui/util/ListUtilsKt\n*L\n1#1,50:1\n6442#2:51\n33#3,6:52\n*S KotlinDebug\n*F\n+ 1 Actual.kt\nandroidx/compose/ui/ActualKt\n*L\n35#1:51\n36#1:52,6\n*E\n"
}
.end annotation
# direct methods
.method public static final a(Ljava/lang/Object;Ljava/lang/Object;)Z
.locals 1
const-string v0, "a"
invoke-static {p0, v0}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullParameter(Ljava/lang/Object;Ljava/lang/String;)V
const-string v0, "b"
invoke-static {p1, v0}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullParameter(Ljava/lang/Object;Ljava/lang/String;)V
invoke-virtual {p0}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
move-result-object p0
invoke-virtual {p1}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
move-result-object p1
if-ne p0, p1, :cond_0
const/4 p0, 0x1
goto :goto_0
:cond_0
const/4 p0, 0x0
:goto_0
return p0
.end method
+1
View File
@@ -24,6 +24,7 @@ include("jadx-plugins:jadx-smali-input")
include("jadx-plugins:jadx-java-convert")
include("jadx-plugins:jadx-rename-mappings")
include("jadx-plugins:jadx-kotlin-metadata")
include("jadx-plugins:jadx-kotlin-source-debug-extension")
include("jadx-plugins:jadx-xapk-input")
include("jadx-plugins:jadx-aab-input")
include("jadx-plugins:jadx-apkm-input")