Files
letro-android/plugins/src/main/kotlin/extension/KoverExtension.kt
Jorge Martin Espinosa 78c9076281 Fix TransactionTooLargeExceptions caused by Appyx (#6410)
* Fix `TransactionTooLargeExceptions` caused by Appyx

After a long debugging session, we discovered the code Appyx uses to clear the saved state of nodes that have been removed is not working because of a race condition, causing this saved state to grow indefinitely.

To fix it, we need to wait until the node has been disposed, which will call `SaveableStateHolder.removeState` once, removing the associated `SaveableStateRegistry`, and *then* call `removeState` again when we detect the node has been removed from the navigation graph.

Since these classes and APIs are private in Appyx, we had to copy and modify and use these copies.

* Remove ktlint checks on `SafeChildrenTransitionScope.kt`

* Don't count the new code for coverage
2026-03-23 18:07:00 +01:00

246 lines
11 KiB
Kotlin

/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package extension
import kotlinx.kover.gradle.plugin.dsl.AggregationType
import kotlinx.kover.gradle.plugin.dsl.CoverageUnit
import kotlinx.kover.gradle.plugin.dsl.GroupingEntityType
import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
import kotlinx.kover.gradle.plugin.dsl.KoverVariantCreateConfig
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import java.io.File
enum class KoverVariant(val variantName: String) {
Presenters("presenters"),
States("states"),
Views("views"),
}
val koverVariants = KoverVariant.entries.map { it.variantName }
val localAarProjects = listOf(
":libraries:rustsdk",
":libraries:textcomposer:lib"
)
val excludedKoverSubProjects = listOf(
":appconfig",
":annotations",
":codegen",
":tests:testutils",
// Exclude modules which are not Android libraries
// See https://github.com/Kotlin/kotlinx-kover/issues/312
":appconfig",
":libraries:core",
":libraries:coroutines",
":libraries:di",
":tests:detekt-rules",
":tests:konsist",
":tests:testutils",
) + localAarProjects
private fun Project.kover(any: Any) {
this.dependencies.add("kover", any)
}
fun Project.setupKover() {
// If the project is excluded from Kover, don't apply anything
if (path in excludedKoverSubProjects) return
// Apply the plugin
apply(plugin = "org.jetbrains.kotlinx.kover")
// Create verify all task joining all existing verification tasks
tasks.register("koverVerifyAll") {
group = "verification"
description = "Verifies the code coverage of all subprojects."
val dependencies = listOf(":koverVerifyMerged") + koverVariants.map { ":app:koverVerify${it.replaceFirstChar(Char::titlecase)}" }
dependsOn(dependencies)
}
// https://kotlin.github.io/kotlinx-kover/
// Run `./gradlew :app:koverHtmlReport` to get report at ./app/build/reports/kover
// Run `./gradlew :app:koverXmlReport` to get XML report
extensions.configure<KoverProjectExtension> {
currentProject {
// Create custom variants for verification
for (variant in koverVariants) {
createVariant(variant) {
defaultVariants(project)
// Using the cache for coverage verification seems to be flaky, so we disable it for now.
val taskName = "koverCachedVerify${variant.replaceFirstChar(Char::titlecase)}"
val cachedTask = project.tasks.findByName(taskName)
cachedTask?.let {
it.outputs.upToDateWhen { false }
}
}
}
// Create merged variant
createVariant("merged") {
defaultVariants(project)
}
}
// If it's the root project, set up kover for subprojects
if (project.path == ":") {
for (project in project.subprojects) {
if (project.path !in excludedKoverSubProjects && File(project.projectDir, "build.gradle.kts").exists()) {
kover(project)
}
}
}
reports {
filters {
excludes {
classes(
// Exclude generated classes.
"*_Module",
"*_AssistedFactory",
"com.airbnb.android.showkase*",
"io.element.android.libraries.designsystem.showkase.*",
"*ComposableSingletons$*",
"*BuildConfig",
// Generated by Showkase
"*Ioelementandroid*PreviewKt$*",
"*Ioelementandroid*PreviewKt",
// Other
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
"*Node",
"*Node$*",
"*Presenter\$present\$*",
// Forked from compose
"io.element.android.libraries.designsystem.theme.components.bottomsheet.*",
// Konsist code to make test fails
"io.element.android.tests.konsist.failures",
// Copied from Appyx
"io.element.android.libraries.architecture.appyx.SafeChildrenTransitionScope",
)
annotatedBy(
"androidx.compose.ui.tooling.preview.Preview",
"io.element.android.libraries.architecture.coverage.ExcludeFromCoverage",
"io.element.android.libraries.designsystem.preview.*",
)
}
}
total {
verify {
// General rule: minimum code coverage.
rule("Global minimum code coverage.") {
groupBy = GroupingEntityType.APPLICATION
bound {
minValue = 70
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
// minValue to 25 and maxValue to 35.
maxValue = 80
coverageUnits = CoverageUnit.INSTRUCTION
aggregationForGroup = AggregationType.COVERED_PERCENTAGE
}
}
}
}
variant(KoverVariant.Presenters.variantName) {
verify {
// Rule to ensure that coverage of Presenters is sufficient.
rule("Check code coverage of presenters") {
groupBy = GroupingEntityType.CLASS
bound {
minValue = 85
coverageUnits = CoverageUnit.INSTRUCTION
aggregationForGroup = AggregationType.COVERED_PERCENTAGE
}
}
}
filters {
excludes.classes(
"*Fake*Presenter*",
"io.element.android.appnav.loggedin.LoggedInPresenter$*",
// Some options can't be tested at the moment
"io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*",
// Need an Activity to use rememberMultiplePermissionsState
"io.element.android.features.location.impl.common.permissions.DefaultPermissionsPresenter",
"*Presenter\$present\$*",
// Too small to be > 85% tested
"io.element.android.libraries.fullscreenintent.impl.DefaultFullScreenIntentPermissionsPresenter",
)
includes.inheritedFrom("io.element.android.libraries.architecture.Presenter")
}
}
variant(KoverVariant.States.variantName) {
verify {
// Rule to ensure that coverage of States is sufficient.
rule("Check code coverage of states") {
groupBy = GroupingEntityType.CLASS
bound {
minValue = 90
coverageUnits = CoverageUnit.INSTRUCTION
aggregationForGroup = AggregationType.COVERED_PERCENTAGE
}
}
}
filters {
excludes.classes(
"*State$*", // Exclude inner classes
"io.element.android.appnav.root.RootNavState",
"io.element.android.features.ftue.api.state.*",
"io.element.android.features.ftue.impl.welcome.state.*",
"io.element.android.features.messages.impl.timeline.model.bubble.BubbleState",
"io.element.android.libraries.designsystem.swipe.SwipeableActionsState",
"io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState",
"io.element.android.libraries.maplibre.compose.CameraPositionState",
"io.element.android.libraries.maplibre.compose.SymbolState",
"io.element.android.libraries.matrix.api.room.RoomMembershipState",
"io.element.android.libraries.matrix.api.room.RoomMembersState",
"io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*",
"io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*",
"io.element.android.libraries.mediaviewer.impl.local.pdf.PdfViewerState",
"io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState",
"io.element.android.libraries.textcomposer.model.TextEditorState",
"io.element.android.libraries.textcomposer.components.FormattingOptionState",
)
includes.classes("*State")
}
}
variant(KoverVariant.Views.variantName) {
verify {
// Rule to ensure that coverage of Views is sufficient (deactivated for now).
rule("Check code coverage of views") {
groupBy = GroupingEntityType.CLASS
bound {
// TODO Update this value, for now there are too many missing tests.
minValue = 0
coverageUnits = CoverageUnit.INSTRUCTION
aggregationForGroup = AggregationType.COVERED_PERCENTAGE
}
}
}
filters {
excludes.classes("*ViewKt$*") // Exclude inner classes
includes.classes("*ViewKt")
}
}
}
}
}
fun KoverVariantCreateConfig.defaultVariants(project: Project) {
if (project.path == ":app") {
addWithDependencies("gplayDebug")
} else {
addWithDependencies("debug", "jvm", optional = true)
}
}