Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2026. Tony Robalik.
// SPDX-License-Identifier: Apache-2.0
package com.autonomousapps.android

import com.autonomousapps.android.projects.ExceptionsAreSpecialProject
import com.autonomousapps.utils.Colors

import static com.autonomousapps.advice.truth.BuildHealthSubject.buildHealth
import static com.autonomousapps.kit.GradleBuilder.build
import static com.google.common.truth.Truth.assertAbout
import static com.google.common.truth.Truth.assertThat

class ExceptionsAreSpecialSpec extends AbstractAndroidSpec {

@SuppressWarnings('GroovyAssignabilityCheck')
def "exception types are correctly mapped to the providing dependencies (#gradleVersion AGP #agpVersion)"() {
given:
def project = new ExceptionsAreSpecialProject(agpVersion)
gradleProject = project.gradleProject

when:
def result = build(gradleVersion, gradleProject.rootDir, ':buildHealth', ':consumer:reason', '--id', 'androidx.navigation:navigation-common:')

then:
assertAbout(buildHealth())
.that(project.actualProjectAdvice())
.isEquivalentIgnoringModuleAdviceAndWarnings(project.expectedProjectAdvice())

and:
assertThat(Colors.decolorize(result.output)).contains(project.expectedReason())

where:
[gradleVersion, agpVersion] << gradleAgpMatrix()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import com.autonomousapps.kit.GradleProject
import com.autonomousapps.kit.Source
import com.autonomousapps.kit.SourceType
import com.autonomousapps.kit.android.AndroidColorRes
import com.autonomousapps.kit.android.AndroidManifest
import com.autonomousapps.kit.android.AndroidStyleRes
import com.autonomousapps.kit.gradle.Dependency
import com.autonomousapps.kit.gradle.Plugin
Expand Down Expand Up @@ -86,16 +85,6 @@ final class AndroidTestSourceProject extends AbstractAndroidProject {

private List<Source> appSources() {
def sources = [
new Source(
SourceType.KOTLIN, 'App', 'com/example',
"""\
package com.example

class App {
fun magic() = 42
}
""".stripIndent()
),
new Source(
SourceType.KOTLIN, 'Test', 'com/example',
"""\
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) 2026. Tony Robalik.
// SPDX-License-Identifier: Apache-2.0
package com.autonomousapps.android.projects

import com.autonomousapps.kit.GradleProject
import com.autonomousapps.kit.Source
import com.autonomousapps.kit.gradle.Dependency
import com.autonomousapps.model.Advice
import com.autonomousapps.model.ProjectAdvice

import static com.autonomousapps.AdviceHelper.*
import static com.autonomousapps.kit.gradle.Dependency.implementation
import static com.autonomousapps.kit.gradle.Dependency.testImplementation

final class ExceptionsAreSpecialProject extends AbstractAndroidProject {

private static final Dependency HILT_IMPL = implementation('androidx.hilt:hilt-navigation-compose:1.2.0')
private static final Dependency LAYOUTLIB_IMPL = testImplementation('com.android.tools.layoutlib:layoutlib:15.2.3')

final GradleProject gradleProject

ExceptionsAreSpecialProject(String agpVersion) {
super(agpVersion)
this.gradleProject = build()
}

private GradleProject build() {
return newAndroidGradleProjectBuilder(agpVersion)
.withAndroidLibProject('consumer') { s ->
s.sources = consumerSources()
s.withBuildScript { bs ->
bs.android = defaultAndroidLibBlock(false, 'com.example.consumer')
bs.plugins(androidLib(false))
bs.dependencies(
HILT_IMPL,
LAYOUTLIB_IMPL,
)
}
}
.write()
}

private static final List<Source> consumerSources() {
return [
Source.java(
'''\
package mutual.aid.consumer;

// layoutlib bundles this class
import android.content.Context;

public abstract class ConsumerTest {
public abstract Context getContext();
}
'''.stripIndent()
)
.withSourceSet('test')
.build()
]
}

Set<ProjectAdvice> actualProjectAdvice() {
return actualProjectAdvice(gradleProject)
}

private final Set<Advice> consumerAdvice() {
[
Advice.ofRemove(moduleCoordinates(HILT_IMPL), HILT_IMPL.configuration),

// These two both provide runtime capabilities and would be removed with the removal of hilt, so we advise they
// be added directly to minimize changes to the runtime classpath.
Advice.ofAdd(moduleCoordinates('com.google.dagger:dagger-lint-aar:2.49'), 'runtimeOnly'),
Advice.ofAdd(moduleCoordinates('org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'), 'runtimeOnly')
]
}

Set<ProjectAdvice> expectedProjectAdvice() {
return [projectAdviceForDependencies(':consumer', consumerAdvice())]
}

String expectedReason() {
return '''\
Source: debug, test
-------------------
(no usages)'''.stripIndent()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import static com.google.common.truth.Truth.assertThat
class ExceptionsAreSpecialSpec extends AbstractJvmSpec {

@SuppressWarnings('GroovyAssignabilityCheck')
def "annotations on public classes are part of the abi (#gradleVersion isBroken=#isBroken)"() {
def "exception types are required at runtime (#gradleVersion isBroken=#isBroken)"() {
given:
def project = new ExceptionsAreSpecialProject(isBroken)
gradleProject = project.gradleProject
Expand Down
Comment thread
autonomousapps marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -209,22 +209,29 @@ internal class StandardTransform(
sourceSetName: String,
): MutableSet<Usage> {
fun MutableSet<Usage>.maybeReduceFurther(): MutableSet<Usage> {
return if (projectType == ProjectType.ANDROID && sourceSetName == SourceKind.TEST_NAME) {
return if (projectType == ProjectType.ANDROID && sourceSetName == SourceKind.TEST_NAME) { // TODO: `&& SourceKind.ANDROID_TEST_NAME`?
reduceUsages(this)
} else {
this
}
}

// TODO(tsr): is there a case where both forCompile and forRuntime are true? What then?
return if (visibility.forCompile && !explicitFor(sourceSetName)) {
asSequence()
.filterNot { usage -> usage.bucket != Bucket.RUNTIME_ONLY }
.toMutableSet()
.maybeReduceFurther()
} else if (visibility.forRuntime && !explicitFor(sourceSetName)) {
asSequence()
.filterNot { usage -> usage.bucket == Bucket.RUNTIME_ONLY }
return if (visibility.forEither && !explicitFor(sourceSetName)) {
// TODO(tsr): add test for this case.
// 1. a dep that should be moved from impl->api
// 2. dep has runtime capabilities as well and is on test runtime classpath
// 3. previously we'd see advice to both move to api and also add to testRuntimeOnly. The latter is undesirable.

// Yes, if something is visible forCompile and forRuntime, then we filter out all usages.
var seq = asSequence()
if (visibility.forCompile) {
seq = seq.filterNot { usage -> usage.bucket != Bucket.RUNTIME_ONLY }
}
if (visibility.forRuntime) {
seq = seq.filterNot { usage -> usage.bucket == Bucket.RUNTIME_ONLY }
}

seq
.toMutableSet()
.maybeReduceFurther()
} else {
Expand Down
24 changes: 23 additions & 1 deletion src/main/kotlin/com/autonomousapps/internal/utils/collections.kt
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ internal inline fun <T, R : Any> Iterable<T>.mapNotNullToOrderedSet(transform: (
return mapNotNullTo(TreeSet(), transform)
}

internal inline fun <T, R : Any, V : Any> Iterable<T>.mapSecondNotNull(transform: (T) -> Pair<R, V?>): List<Pair<R, V>> {
internal inline fun <T, R : Any, V : Any> Iterable<T>.mapSecondNotNull(
transform: (T) -> Pair<R, V?>
): List<Pair<R, V>> {
return mapNotNull {
val pair = transform(it)
if (pair.second != null) {
Expand All @@ -158,6 +160,26 @@ internal inline fun <T, R : Any, V : Any> Iterable<T>.mapSecondNotNull(transform
}
}

internal inline fun <T, R : Any, V : Any> Sequence<T>.mapSecondNotNull(
crossinline transform: (T) -> Pair<R, V?>
): Sequence<Pair<R, V>> {
return mapNotNull {
val pair = transform(it)
if (pair.second != null) {
@Suppress("UNCHECKED_CAST")
pair as Pair<R, V>
} else {
null
}
}
}

internal inline fun <T : Any, K : Any, V : Any> Sequence<T>.associateNotNull(
crossinline transform: (T) -> Pair<K, V>?
): Map<K, V> {
return mapNotNull { transform(it) }.toMap()
}

/**
* Sort elements keeping Comparable-equal elements (stable sorting).
* This method has different semantics with standard toSortedSet(Comparator).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,7 @@ internal enum class Bucket(val value: String) {
}
}

class Visibility(val forCompile: Boolean, val forRuntime: Boolean)
class Visibility(val forCompile: Boolean, val forRuntime: Boolean) {
val forEither: Boolean get() = forCompile || forRuntime
}
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate bug discovered when using reason.

Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ internal sealed class Reason(open val reason: String) {
@TypeLabel("lint")
@JsonClass(generateAdapter = false)
data class LintJar(override val reason: String) : Reason(reason) {
override val configurationName: String = "implementation"
override val configurationName: String = "runtimeOnly"

internal companion object {
fun of(lintRegistry: String) = LintJar(
Expand Down
40 changes: 30 additions & 10 deletions src/main/kotlin/com/autonomousapps/tasks/ComputeUsagesTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -698,21 +698,43 @@ private class GraphVisitor(
capability: ExceptionCapability,
context: GraphViewVisitor.Context,
): Boolean {
// TODO(tsr): extract this to a testable function
val codeSource = context.project.codeSource
if (codeSource.isEmpty()) {
// nothing to do here
// Nothing to do here. We don't have any code, so we can't be referring to any exception types, by definition.
// This is an optimization.
return false
}

// These are all the exception types referenced by `coordinates`, which is a specific dependency of `this` project
// (i.e., `context.project`).
val exceptions = capability.exceptions.values.flatten().toSet()
// These are all the exception types referenced by `this` dependency.
val referencedExceptions = capability.exceptions.values.flatten().toSet()

// Ideally this would be a single item, but it could be multiple if we have duplicates on the classpath.
val exceptionProviders: Map<Coordinates, Set<String>> = context.dependencies
// We only care about dependencies that have classes
.mapSecondNotNull { dependency -> dependency.coordinates to dependency.findCapability<BinaryClassCapability>() }
// Find the dependencies that have a class that matches any exception referenced by `this` dependency.
.mapNotNull { (coordinates, binaryClass) ->
val relevantTypes = binaryClass.classes.filterToOrderedSet { it in referencedExceptions }
if (relevantTypes.isEmpty()) {
null
} else {
coordinates to relevantTypes
}
}
.toMap()

if (exceptionProviders.isEmpty()) {
// Nothing to do here.
return false
}

// TODO(tsr): extract this to testable function
// These are all the dependencies of this project that use the exception types provided by `coordinates`.
val users: Map<Coordinates, Map<String, Set<String>>> = context.dependencies
val users: Map<Coordinates, Map<String, Set<String>>> = context.dependencies.asSequence()
// The dependency can see all its own exceptions
.filterNot { dependency -> dependency.coordinates == coordinates }
// Don't scan the exception providers, that doesn't make sense and leads to incorrect results
.filterNot { dependency -> dependency.coordinates in exceptionProviders.keys }
// If the dependency's runtime graph can reach `this` dependency, then we can skip analysis
.filterNot { dependency ->
val graph = context.graphRuntime.graph
Expand All @@ -728,17 +750,15 @@ private class GraphVisitor(
.associateNotNull { (coordinates, exceptionCapability) ->
val classToExceptionType = exceptionCapability.exceptions
.mapNotNull { (className, types) ->
val relevantTypes = types.filterToOrderedSet { type -> type in exceptions }
val relevantTypes = types.filterToOrderedSet { type -> type in referencedExceptions }
if (relevantTypes.isEmpty()) {
null
} else {
className to relevantTypes
}
}
// It only matters if the code of `this` project refers to a class that itself references a relevant type
.filter { (className, _) ->
codeSource.any { source -> className in source.usedNonAnnotationClasses }
}
.filter { (className, _) -> codeSource.any { source -> className in source.usedNonAnnotationClasses } }
.toMap()

if (classToExceptionType.isNotEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class DependencyAdviceExplainerTest {
* Uses 6 classes, 5 of which are shown: One, Two, Three, Four, Five (implies implementation).
* Imports 6 classes, 5 of which are shown: One, Two, Three, Four, Five (implies implementation).
* Imports 6 inline members, 5 of which are shown: One, Two, Three, Four, Five (implies implementation).
* Provides 1 lint registry: LintRegistry (implies implementation).
* Provides 1 lint registry: LintRegistry (implies runtimeOnly).
* Provides 2 native binaries: foo, bar (implies runtimeOnly).
* Imports 2 resources: drawable, string (implies implementation).
* Uses 2 resources: StyleParentRef(styleParent=AppCompat), AttrRef(type=drawable, id=logo) (implies implementation).
Expand Down