Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

### Changes

* Добавлена валидация публичного ABI публикуемых модулей: слепок каждого модуля хранится в `api/<модуль>.api`, таск `apiCheck` входит в `check`, слепки обновляются командой `./gradlew apiDump`. Объявления, помеченные `@DebugPanelInternal`, в слепок не попадают. См. [plugin development](docs/plugin_development.md).
* Добавлена валидация полноты `panel-no-op`: таск `checkNoopApi` проверяет, что модуль покрывает весь публичный API `panel-core` и плагинов.
* **Breaking changes:** Приведены в соответствие с оригиналом no-op объявления: `DebugEvent` перенесён из `com.redmadrobot.debug.core.internal` в `com.redmadrobot.debug.core`, `AboutAppAction` и `AboutAppInfo` — в `com.redmadrobot.debug.plugin.aboutapp.model`. См. [migration guide](docs/migration_guide.md).
* В `panel-no-op` добавлены отсутствовавшие `DebugPanel.isInitialized`, `AboutAppInfo.id`, `AboutAppAction.Event.debugEvent`, `ServersPlugin.getSelectedServer()` и `ServersPlugin.getDefaultServer()`; удалён `DebugPanel.showPanel(FragmentManager)`, которого нет в `panel-core`.
* Обновлён каталог версий зависимостей (2026.07.10 → 2026.07.31).
* Gradle обновлён с 9.4.1 до 9.6.1.

## [1.3.0] (2026-07-30)

### Changes

* **Breaking changes:** `plugin-konfeature` переведён на публичную библиотеку [`konfeature-ui`][konfeature-ui]. Собственная реализация экрана, `ViewModel`, диалога редактирования и `JsonConverter` удалены — UI, состояние и хранение переопределений теперь предоставляет `konfeature-ui`. См. [migration guide](docs/migration_guide.md).
* **Breaking changes:** Удалён `KonfeatureDebugPanelInterceptor`. Вместо него используется `KonfeatureDebugPanelConfig`, который объединяет хранилище переопределений (DataStore) и интерцептор. Создайте конфиг через `KonfeatureDebugPanelConfig.create(context)`, подключите его к `Konfeature` через `applyDebugPanelConfig(config)` и передайте тот же конфиг в `KonfeaturePlugin(konfeature, config)`.
* **Breaking changes:** Изменена сигнатура конструктора `KonfeaturePlugin`: параметр `debugPanelInterceptor` заменён на `config: KonfeatureDebugPanelConfig`.
Expand Down
2 changes: 1 addition & 1 deletion buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dependencies {
implementation(stack.kotlin.composeCompiler.gradlePlugin)
implementation(stack.detekt.gradlePlugin)
implementation(stack.android.tools.build.gradle)

implementation(stack.kotlinx.binaryCompatibilityValidator)
// Hack-around to access version catalogs inside precompiled script plugins
// See: https://github.com/gradle/gradle/issues/15383#issuecomment-779893192
implementation(files(androidx.javaClass.superclass.protectionDomain.codeSource.location))
Expand Down
2 changes: 1 addition & 1 deletion buildSrc/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ dependencyResolutionManagement {
}

versionCatalogs {
val version = "2026.07.10" // Keep it in sync with root settings.gradle.kts
val version = "2026.07.31" // Keep it in sync with root settings.gradle.kts
create("rmr") {
from("com.redmadrobot.versions:versions-redmadrobot:$version")
}
Expand Down
64 changes: 64 additions & 0 deletions buildSrc/src/main/kotlin/convention.abi.validation.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import internal.Versions
import internal.stack
import kotlinx.validation.KotlinApiBuildTask
import kotlinx.validation.KotlinApiCompareTask
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile

/*
* Public ABI validation for Android library modules.
*
* The reference dump lives in `<module>/api/<module>.api` and is verified by `check`.
* Run `./gradlew apiDump` to update dumps after an intentional API change.
*
* Note: the ABI validation built into the Kotlin Gradle plugin (`kotlin { abiValidation { } }`)
* cannot be used here. KGP registers its setup actions only from `org.jetbrains.kotlin.android`,
* and since AGP 9 provides Kotlin support itself that plugin must not be applied — so the DSL is
* present but inert. The tasks are wired manually instead; the dump format is identical, so the
* files stay valid once the built-in validation becomes usable.
*/

val abiTools = configurations.dependencyScope("abiTools")
val abiToolsClasspath = configurations.resolvable("abiToolsClasspath") {
extendsFrom(abiTools.get())
}

dependencies {
add(abiTools.name, "org.ow2.asm:asm:${Versions.ASM}")
add(abiTools.name, "org.ow2.asm:asm-tree:${Versions.ASM}")
add(abiTools.name, "org.jetbrains.kotlin:kotlin-metadata-jvm:${stack.versions.kotlin.get()}")
}

val apiFileName = "${project.name}.api"
val referenceApiDir = layout.projectDirectory.dir("api")
// Taken from the compile tasks rather than from `kotlin.target.compilations`: with AGP's built-in
// Kotlin support the compilation outputs are not populated, so the collection would come out empty.
val releaseClasses = files(
provider { tasks.named<KotlinJvmCompile>("compileReleaseKotlin").flatMap { it.destinationDirectory } },
provider { tasks.named<JavaCompile>("compileReleaseJavaWithJavac").flatMap { it.destinationDirectory } },
)

val apiBuild = tasks.register<KotlinApiBuildTask>("apiBuild") {
description = "Dumps the public ABI of the 'release' variant into the build directory."
runtimeClasspath.from(abiToolsClasspath)
inputClassesDirs.from(releaseClasses)
outputApiFile.set(layout.buildDirectory.file("api/$apiFileName"))
nonPublicMarkers.add("com.redmadrobot.debug.core.annotation.DebugPanelInternal")
}

val apiCheck = tasks.register<KotlinApiCompareTask>("apiCheck") {
description = "Checks that the public ABI matches the reference dump in the 'api' directory."
group = LifecycleBasePlugin.VERIFICATION_GROUP
projectApiFile.set(referenceApiDir.file(apiFileName))
generatedApiFile.set(apiBuild.flatMap { it.outputApiFile })
}

tasks.register<Sync>("apiDump") {
description = "Overwrites the reference ABI dump with the ABI of the current code."
group = LifecycleBasePlugin.VERIFICATION_GROUP
from(apiBuild.flatMap { it.outputApiFile })
into(referenceApiDir)
}

tasks.named(LifecycleBasePlugin.CHECK_TASK_NAME) {
dependsOn(apiCheck)
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
id("convention-publish")
id("convention.compose")
id("convention.detekt")
id("convention.abi.validation")
}

android {
Expand Down
246 changes: 246 additions & 0 deletions buildSrc/src/main/kotlin/internal/CheckNoopApiTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package internal

import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import java.io.File

/**
* Verifies that the no-op module stays a drop-in replacement for the real ones: every public
* declaration of the mirrored modules (`panel-core` and the plugins) must be repeated in the no-op
* module under the same fully qualified name and with the same members, so that swapping
* `debugImplementation` for `releaseImplementation` keeps the consumer code compiling.
*
* The comparison is made on the ABI dumps produced by `convention.abi.validation`, so any change
* of the public API fails the build until it is mirrored in the no-op module (or hidden from the
* dump by making the declaration `internal`).
*
* The panel's own machinery is not part of the contract and is skipped: declarations from the
* [INTERNAL_TYPE_PREFIXES] packages, from `internal`/`ui` packages, and every member that mentions
* such a type, is Compose-related, or is a `kotlinx.serialization` synthetic. That is exactly the
* surface a plugin implementation uses and an application does not.
*/
abstract class CheckNoopApiTask : DefaultTask() {

/** ABI dump of the no-op module. */
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val noopDump: RegularFileProperty

/** ABI dumps of the modules the no-op module must mirror. */
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
abstract val mirroredDumps: ConfigurableFileCollection

@TaskAction
fun check() {
val dumps = mirroredDumps.files.sortedBy(File::getPath)
check(dumps.isNotEmpty()) { "No ABI dumps to compare the no-op module against." }
val absentDumps = dumps.filterNot(File::isFile)
check(absentDumps.isEmpty()) {
"ABI dumps are not generated yet, run './gradlew apiDump':\n" +
absentDumps.joinToString("\n") { " $it" }
}

val noopModule = noopDump.get().asFile.nameWithoutExtension
val expected = declarations(dumps.flatMap(::parse), dropInternal = true)
val actual = declarations(parse(noopDump.get().asFile), dropInternal = false)

val problems = buildList {
(expected - actual.keys).values.forEach { declaration ->
add("Missing in $noopModule:\n" + render(declaration).prependIndent(" "))
}
(actual - expected.keys).values.forEach { declaration ->
add("Not part of the mirrored public API:\n" + render(declaration).prependIndent(" "))
}
expected.keys.intersect(actual.keys).forEach { name ->
diff(expected.getValue(name), actual.getValue(name), noopModule)?.let(::add)
}
}
if (problems.isEmpty()) return

error(
buildString {
appendLine("$noopModule does not cover the public API of the mirrored modules.")
appendLine()
appendLine(
"Mirror the declarations below in $noopModule keeping the original package, " +
"or make them `internal` if they are not meant for library consumers. " +
"Run './gradlew apiDump' afterwards to update the ABI dumps."
)
appendLine()
append(problems.joinToString("\n\n"))
},
)
}

private companion object {

/**
* Types that are the panel's own machinery, not part of the no-op contract: `panel-core`
* packages meant for plugin implementations, Compose, and serialization synthetics.
*/
val INTERNAL_TYPE_PREFIXES = listOf(
"com/redmadrobot/debug/core/annotation/",
"com/redmadrobot/debug/core/extension/",
"com/redmadrobot/debug/core/inapp/",
"com/redmadrobot/debug/core/plugin/",
"com/redmadrobot/debug/uikit/",
"androidx/compose/",
"kotlinx/serialization/",
)

/** Package segments marking declarations internal to the panel, e.g. plugin screens. */
val INTERNAL_PACKAGE_SEGMENTS = setOf("internal", "ui")

/** Keywords a member line starts with, right after its modifiers. */
val MEMBER_KEYWORDS = listOf("fun ", "field ")

/** Matches a class reference inside a JVM descriptor, e.g. `Lcom/redmadrobot/debug/Foo;`. */
val TYPE_REGEX = Regex("""L([\w/$]+);""")

/** Synthetic field added by the Compose compiler; the no-op module has no Compose. */
const val STABLE_FIELD = " field \$stable "

fun isInternal(type: String): Boolean {
return INTERNAL_TYPE_PREFIXES.any(type::startsWith) ||
type.split('/').dropLast(1).any { it in INTERNAL_PACKAGE_SEGMENTS }
}

/** Member without its modifiers, so that an override matches the declaration it overrides. */
fun signature(member: String): String {
val keyword = MEMBER_KEYWORDS.firstOrNull { it in member } ?: return member
return member.substring(member.indexOf(keyword))
}

fun isInternal(member: String, dropped: Set<String>): Boolean {
return STABLE_FIELD in member ||
TYPE_REGEX.findAll(member).any { it.groupValues[1].let { type -> isInternal(type) || type in dropped } }
}

/**
* Splits an ABI dump into declarations: a header line plus its indented members.
* Compiler-generated (`synthetic`) classes are skipped, as are the members that are not
* part of the no-op contract.
*/
fun parse(file: File): List<Declaration> {
return file.readLines()
.filterNot { it.isBlank() || it.startsWith("//") }
.fold(mutableListOf<MutableList<String>>()) { blocks, line ->
if (line.first().isWhitespace()) blocks.last() += line.trim() else blocks += mutableListOf(line)
blocks
}
.mapNotNull(::parseDeclaration)
}

fun parseDeclaration(block: List<String>): Declaration? {
val header = block.first().removeSuffix(" {")
val modifiers = header.substringBefore("class ").trim()
if ("synthetic" in modifiers.split(' ')) return null

val declaration = header.substringAfter("class ")
val name = declaration.substringBefore(" : ")
val supertypes = declaration.substringAfter(" : ", missingDelimiterValue = "")
.split(", ")
.filter(String::isNotEmpty)
val publicSupertypes = supertypes.filterNot(::isInternal).sorted()

return Declaration(
name = name,
header = "$modifiers class $name" +
if (publicSupertypes.isEmpty()) "" else publicSupertypes.joinToString(", ", prefix = " : "),
supertypes = supertypes,
members = block.drop(1).filterNot { isInternal(it, dropped = emptySet()) }.toSet(),
)
}

/**
* Members a declaration only has because it implements a panel-internal supertype, e.g.
* `Plugin.getName()`. They are a part of the plugin machinery rather than of the API the
* application calls, so the no-op module does not repeat them. Constructors are kept:
* they are not inherited.
*/
fun inheritedFromInternal(declaration: Declaration, index: Map<String, Declaration>): Set<String> {
val inherited = mutableSetOf<String>()

fun collect(name: String) {
val supertype = index[name] ?: return
supertype.members
.filterNot { "fun <init> " in it }
.mapTo(inherited, ::signature)
supertype.supertypes.forEach(::collect)
}

declaration.supertypes.filter(::isInternal).forEach(::collect)
return inherited
}

/**
* Indexes declarations by name, dropping the ones the no-op module does not have to
* mirror. A companion left without members (it only held a serializer, for example) is
* dropped together with the field referencing it.
*/
fun declarations(all: List<Declaration>, dropInternal: Boolean): Map<String, Declaration> {
val index = all.associateBy(Declaration::name)
val kept = if (dropInternal) all.filterNot { isInternal(it.name) } else all
val emptyCompanions = kept
.filter { it.name.endsWith("\$Companion") && it.members.isEmpty() }
.mapTo(mutableSetOf()) { it.name }

return kept
.filterNot { it.name in emptyCompanions }
.associateBy(
keySelector = Declaration::name,
valueTransform = { declaration ->
val inherited = inheritedFromInternal(declaration, index)
declaration.copy(
members = declaration.members
.filterNot { signature(it) in inherited || isInternal(it, emptyCompanions) }
.toSet(),
)
},
)
.toSortedMap()
}

fun diff(expected: Declaration, actual: Declaration, noopModule: String): String? {
val missingMembers = expected.members - actual.members
val extraMembers = actual.members - expected.members
if (expected.header == actual.header && missingMembers.isEmpty() && extraMembers.isEmpty()) return null

return buildString {
appendLine("${expected.name} differs:")
if (expected.header != actual.header) {
appendLine(" declaration:")
appendLine(" expected: ${expected.header}")
appendLine(" actual: ${actual.header}")
}
if (missingMembers.isNotEmpty()) {
appendLine(" missing in $noopModule:")
missingMembers.sorted().forEach { appendLine(" $it") }
}
if (extraMembers.isNotEmpty()) {
appendLine(" not part of the mirrored public API:")
extraMembers.sorted().forEach { appendLine(" $it") }
}
}.trimEnd()
}

fun render(declaration: Declaration): String {
val members = declaration.members.sorted().joinToString(separator = "") { "\n\t$it" }
return "${declaration.header} {$members\n}"
}
}

private data class Declaration(
val name: String,
val header: String,
val supertypes: List<String>,
val members: Set<String>,
)
}
3 changes: 3 additions & 0 deletions buildSrc/src/main/kotlin/internal/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ internal object Versions {
const val MIN_SDK = 23
const val TARGET_SDK = 36
const val COMPILE_SDK = 37

/** Bytecode reader used by binary-compatibility-validator workers. Keep in sync with the plugin's own version. */
const val ASM = "9.6"
}
Loading
Loading