diff --git a/src/functionalTest/groovy/com/autonomousapps/android/ResSpec.groovy b/src/functionalTest/groovy/com/autonomousapps/android/ResSpec.groovy index 6831b80d8..89e17d20e 100644 --- a/src/functionalTest/groovy/com/autonomousapps/android/ResSpec.groovy +++ b/src/functionalTest/groovy/com/autonomousapps/android/ResSpec.groovy @@ -4,9 +4,11 @@ package com.autonomousapps.android import com.autonomousapps.android.projects.* import org.gradle.testkit.runner.TaskOutcome +import spock.lang.PendingFeature import static com.autonomousapps.advice.truth.BuildHealthSubject.buildHealth -import static com.autonomousapps.utils.Runner.build +import static com.autonomousapps.kit.GradleBuilder.build +import static com.autonomousapps.kit.GradleBuilder.buildAndFail import static com.google.common.truth.Truth.assertAbout @SuppressWarnings("GroovyAssignabilityCheck") @@ -203,4 +205,35 @@ final class ResSpec extends AbstractAndroidSpec { where: [gradleVersion, agpVersion] << gradleAgpMatrix() } + + // TODO(tsr): this test case demonstrates (or more accurately: fails to demonstrate) any issue when multiple Android + // libraries provide a resource with the same name, and the consumer side only references that resource in an XML + // file (so there's no qualified reference in code for more accurate detection). + // See e.g. https://developer.android.com/studio/projects/android-library#Considerations for information on resource + // merging; and + // https://developer.android.com/reference/tools/gradle-api/8.3/null/com/android/build/api/dsl/CommonExtension#setResourcePrefix(kotlin.String) + // for an AGP DSL that can help mitigate the problem (set unique prefixes everywhere and use them). + // This is annotated with `@PendingFeature` because the assertion is that the build fails (but the build succeeds). If + // there is ever any improvement in this scenario, this case will begin to really fail and then we can update the + // assertions. + @PendingFeature(reason = "https://github.com/autonomousapps/dependency-analysis-gradle-plugin/issues/1731") + def "handles duplicate res-by-res producers (#gradleVersion AGP #agpVersion)"() { + given: + def project = new ResByResProject(agpVersion) + gradleProject = project.gradleProject + + when: + buildAndFail(gradleVersion, gradleProject.rootDir, 'buildHealth') + // The output is identical +// build(gradleVersion, gradleProject.rootDir, ':app:reason', '--id', ':res1') +// build(gradleVersion, gradleProject.rootDir, ':app:reason', '--id', ':res2') + + then: + assertAbout(buildHealth()) + .that(project.actualBuildHealth()) + .isEquivalentIgnoringModuleAdviceAndWarnings(project.expectedBuildHealth) + + where: + [gradleVersion, agpVersion] << gradleAgpMatrix() + } } diff --git a/src/functionalTest/groovy/com/autonomousapps/android/projects/AbstractAndroidProject.groovy b/src/functionalTest/groovy/com/autonomousapps/android/projects/AbstractAndroidProject.groovy index 50e3b7421..12f0e5b07 100644 --- a/src/functionalTest/groovy/com/autonomousapps/android/projects/AbstractAndroidProject.groovy +++ b/src/functionalTest/groovy/com/autonomousapps/android/projects/AbstractAndroidProject.groovy @@ -110,6 +110,10 @@ abstract class AbstractAndroidProject extends AbstractProject { return AndroidManifest.appWithoutPackage(namespace) } + protected AndroidManifest appEmpty() { + return AndroidManifest.appEmpty() + } + protected AndroidManifest libraryManifest(String namespace = DEFAULT_LIB_NAMESPACE) { return null } diff --git a/src/functionalTest/groovy/com/autonomousapps/android/projects/ResByResProject.groovy b/src/functionalTest/groovy/com/autonomousapps/android/projects/ResByResProject.groovy new file mode 100644 index 000000000..5c6d43d1b --- /dev/null +++ b/src/functionalTest/groovy/com/autonomousapps/android/projects/ResByResProject.groovy @@ -0,0 +1,92 @@ +// 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.android.AndroidLayout +import com.autonomousapps.kit.android.AndroidStringRes +import com.autonomousapps.kit.android.AndroidSubproject +import com.autonomousapps.model.ProjectAdvice + +import static com.autonomousapps.AdviceHelper.actualProjectAdvice +import static com.autonomousapps.AdviceHelper.emptyProjectAdviceFor +import static com.autonomousapps.kit.gradle.Dependency.project + +/** + * In this app project, there is a layout file that references a string resource by name (not fully-qualified, because + * XML). Two separate Android libraries provide this same resource. The project has `android.nonTransitiveRClass` + * enabled. Is this a problem? + */ +final class ResByResProject extends AbstractAndroidProject { + + final GradleProject gradleProject + private final String agpVersion + + ResByResProject(String agpVersion) { + super(agpVersion) + this.agpVersion = agpVersion + this.gradleProject = build() + } + + private GradleProject build() { + return newAndroidGradleProjectBuilder(agpVersion) + .withAndroidSubproject('app') { app -> + app.withBuildScript { bs -> + bs.plugins(androidApp()) + bs.android = defaultAndroidAppBlock(false) + bs.dependencies( + project('implementation', ':res1'), + project('implementation', ':res2'), + ) + } + app.manifest = appEmpty() + app.layouts( + AndroidLayout + .named('string_layout.xml') + .withContent('''\ + + + + + '''.stripIndent()) + ) + } + .withAndroidLibProject('res1') { lib -> + configureResLib(lib, 'res1') + } + .withAndroidLibProject('res2') { lib -> + configureResLib(lib, 'res2') + } + .write() + } + + private void configureResLib(AndroidSubproject.Builder lib, String name) { + lib.withBuildScript { bs -> + bs.plugins(androidLib(false)) + bs.android = defaultAndroidLibBlock(false, "com.example.$name") + } + lib.manifest = libraryManifest("com.example.$name") + lib.strings = new AndroidStringRes( + '''\ + + OK + '''.stripIndent() + ) + } + + Set actualBuildHealth() { + return actualProjectAdvice(gradleProject) + } + + final Set expectedBuildHealth = [ + emptyProjectAdviceFor(':app'), + emptyProjectAdviceFor(':res1'), + emptyProjectAdviceFor(':res2'), + ] +} diff --git a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidLayout.kt b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidLayout.kt index 60f7f8e5d..037d8785f 100644 --- a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidLayout.kt +++ b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidLayout.kt @@ -6,5 +6,21 @@ public class AndroidLayout( public val filename: String, public val content: String, ) { + override fun toString(): String = content + + public class Builder(private val name: String) { + public fun withContent(content: String): AndroidLayout { + return AndroidLayout( + filename = name, + content = content, + ) + } + } + + public companion object { + /** The [name] should be just the filename. The path (e.g., `src/main/res/layout/`) is assumed. */ + @JvmStatic + public fun named(name: String): Builder = Builder(name) + } } diff --git a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidSubproject.kt b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidSubproject.kt index b4a24eef9..bcebb582d 100644 --- a/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidSubproject.kt +++ b/testkit/gradle-testkit-support/src/main/kotlin/com/autonomousapps/kit/android/AndroidSubproject.kt @@ -38,7 +38,7 @@ public class AndroidSubproject( public var styles: AndroidStyleRes? = null public var strings: AndroidStringRes? = null public var colors: AndroidColorRes? = null - public var layouts: List? = null + public var layouts: MutableList = mutableListOf() public val files: MutableList = mutableListOf() // sub-builders @@ -63,6 +63,10 @@ public class AndroidSubproject( } } + public fun layouts(vararg layouts: AndroidLayout) { + this.layouts.addAll(layouts.toMutableList()) + } + public fun withFile(path: String, content: String) { withFile(File(path, content)) }