Skip to content

Commit f811067

Browse files
fix: improve handling of Android product flavors.
1 parent eb53672 commit f811067

12 files changed

Lines changed: 349 additions & 16 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026. Tony Robalik.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package com.autonomousapps.android
4+
5+
import com.autonomousapps.android.projects.ProductFlavorsProject
6+
7+
import static com.autonomousapps.advice.truth.BuildHealthSubject.buildHealth
8+
import static com.autonomousapps.kit.GradleBuilder.build
9+
import static com.google.common.truth.Truth.assertAbout
10+
11+
@SuppressWarnings('GroovyAssignabilityCheck')
12+
class ProductFlavorsSpec extends AbstractAndroidSpec {
13+
14+
def "plugin accounts for android resource usage (#gradleVersion AGP #agpVersion)"() {
15+
given:
16+
def project = new ProductFlavorsProject(agpVersion)
17+
gradleProject = project.gradleProject
18+
19+
when:
20+
build(gradleVersion, gradleProject.rootDir, 'buildHealth')
21+
22+
then:
23+
assertAbout(buildHealth())
24+
.that(project.actualBuildHealth())
25+
.isEquivalentIgnoringModuleAdviceAndWarnings(project.expectedBuildHealth)
26+
27+
where:
28+
[gradleVersion, agpVersion] << gradleAgpMatrix()
29+
}
30+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026. Tony Robalik.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package com.autonomousapps.android.projects
4+
5+
import com.autonomousapps.kit.GradleProject
6+
import com.autonomousapps.kit.Source
7+
import com.autonomousapps.kit.gradle.Dependency
8+
import com.autonomousapps.model.ProjectAdvice
9+
10+
import static com.autonomousapps.AdviceHelper.actualProjectAdvice
11+
import static com.autonomousapps.AdviceHelper.emptyProjectAdviceFor
12+
13+
final class ProductFlavorsProject extends AbstractAndroidProject {
14+
15+
private static final FIRE = new Dependency('fireImplementation', ':fire')
16+
private static final FIRE_DEBUG = new Dependency('fireDebugImplementation', ':firedebug')
17+
18+
final GradleProject gradleProject
19+
private final String agpVersion
20+
21+
ProductFlavorsProject(String agpVersion) {
22+
super(agpVersion)
23+
this.agpVersion = agpVersion
24+
this.gradleProject = build()
25+
}
26+
27+
private GradleProject build() {
28+
return newAndroidGradleProjectBuilder(agpVersion)
29+
.withAndroidLibProject('consumer') { consumer ->
30+
consumer.withBuildScript { bs ->
31+
bs.plugins = androidLib(false)
32+
bs.android = defaultAndroidLibBlock(false, 'com.example.consumer')
33+
bs.dependencies(FIRE, FIRE_DEBUG)
34+
bs.withGroovy(
35+
'''\
36+
android {
37+
buildTypes {
38+
staging {
39+
initWith debug
40+
matchingFallbacks = ['debug', 'release']
41+
}
42+
}
43+
44+
flavorDimensions 'element'
45+
productFlavors {
46+
fire { dimension 'element' }
47+
water { dimension 'element' }
48+
}
49+
}
50+
51+
// Initialize a placeholder, since AGP creates this Configuration "late."
52+
configurations {
53+
fireDebugImplementation
54+
}
55+
'''.stripIndent()
56+
)
57+
}
58+
consumer.sources = consumerSources()
59+
consumer.manifest = libraryManifest('com.example.consumer')
60+
}
61+
.withSubproject('fire') { lib ->
62+
lib.withBuildScript { bs ->
63+
bs.plugins = javaLibrary
64+
}
65+
lib.sources = fireSources()
66+
}
67+
.withSubproject('firedebug') { lib ->
68+
lib.withBuildScript { bs ->
69+
bs.plugins = javaLibrary
70+
}
71+
lib.sources = fireDebugSources()
72+
}
73+
.write()
74+
}
75+
76+
private static List<Source> consumerSources() {
77+
[
78+
Source.java(
79+
'''\
80+
package com.example.consumer;
81+
82+
import com.example.fire.Fire;
83+
84+
public class Consumer {
85+
private Fire fire = new Fire();
86+
}
87+
'''.stripIndent()
88+
)
89+
.withSourceSet('fire')
90+
.build(),
91+
Source.java(
92+
'''\
93+
package com.example.consumer.debug;
94+
95+
import com.example.firedebug.FireDebug;
96+
97+
public class ConsumerDebug {
98+
private FireDebug fire = new FireDebug();
99+
}
100+
'''.stripIndent()
101+
)
102+
.withSourceSet('fireDebug')
103+
.build(),
104+
]
105+
}
106+
107+
private static List<Source> fireSources() {
108+
[
109+
Source.java(
110+
'''\
111+
package com.example.fire;
112+
113+
public class Fire {}
114+
'''.stripIndent()
115+
).build(),
116+
]
117+
}
118+
119+
private static List<Source> fireDebugSources() {
120+
[
121+
Source.java(
122+
'''\
123+
package com.example.firedebug;
124+
125+
public class FireDebug {}
126+
'''.stripIndent()
127+
).build(),
128+
]
129+
}
130+
131+
Set<ProjectAdvice> actualBuildHealth() {
132+
return actualProjectAdvice(gradleProject)
133+
}
134+
135+
final Set<ProjectAdvice> expectedBuildHealth = [
136+
emptyProjectAdviceFor(':consumer'),
137+
emptyProjectAdviceFor(':fire'),
138+
emptyProjectAdviceFor(':firedebug'),
139+
]
140+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) 2026. Tony Robalik.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package com.autonomousapps.internal.android
4+
5+
import java.io.Serializable
6+
7+
/** Only public because it's a task input. */
8+
public data class ProductFlavor(
9+
val dimension: String,
10+
val flavorName: String,
11+
) : Serializable

src/main/kotlin/com/autonomousapps/internal/transform/StandardTransform.kt

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -425,19 +425,22 @@ internal class StandardTransform(
425425
}
426426

427427
/**
428-
* Simply advice by transforming matching pairs of add-advice and remove-advice into a single change-advice. In
429-
* addition, strip advice that would add redundant declarations in related source sets, or which would upgrade test
428+
* Simply advice by transforming matching pairs of add-advice and remove-advice into a single change-advice. Also
429+
* strips out confusing and verbose advice to change/add dependencies for the same product flavor.
430+
*
431+
* In addition, strip advice that would add redundant declarations in related source sets, or which would upgrade test
430432
* dependencies.
431433
*/
432434
private fun simplify(advice: MutableSet<Advice>): Set<Advice> {
433-
val (add, remove) = advice.mutPartitionOf(
435+
val (add, remove, change) = advice.mutPartitionOf(
434436
{ it.isAdd() || it.isCompileOnly() },
435-
{ it.isRemove() || it.isRemoveCompileOnly() }
437+
{ it.isRemove() || it.isRemoveCompileOnly() },
438+
{ it.isAnyChange() },
436439
)
437440

438441
add.forEach { theAdd ->
439442
remove
440-
.find { it.coordinates == theAdd.coordinates }
443+
.find { theRemove -> theRemove.coordinates == theAdd.coordinates }
441444
?.let { theRemove ->
442445
// Replace add + remove => change.
443446
advice -= theAdd
@@ -452,6 +455,49 @@ internal class StandardTransform(
452455
}
453456
}
454457

458+
// Look for conflicting advice relating to Android product flavors.
459+
// Previously if we had a declaration like `fireImplementation("foo")` (with product flavors "fire" and "water"),
460+
// then we'd erroneously advise changing that to `fireDebugImplementation`. There was also a transient advice to ADD
461+
// to `fireReleaseImplementation`, but that got filtered out by `isDeclaredInRelatedSourceSet()` below. Together,
462+
// those two pieces of advice are nonsensical. This was happening due to missing support for product flavors. The
463+
// block below handles this by checking change/add advice to see if they're for the same source kind and product
464+
// flavor and, if so, removing them.
465+
// See `ProductFlavorsSpec`.
466+
if (projectType == ProjectType.ANDROID) {
467+
change.forEach { theChange ->
468+
val configurationName = theChange.fromConfiguration!!
469+
val theChangeSourceKind = configurationNames
470+
.sourceKindFrom(configurationName, false)
471+
// We only care about the `kind` (MAIN, TEST, etc.), not full equality of SourceKind.
472+
?.kind
473+
// If it's null, can't make a reasonable equality check, so exit.
474+
?: return@forEach
475+
476+
val theChangeFlavor = configurationNames.findProductFlavorFrom(configurationName)
477+
478+
var removed = false
479+
add.asSequence()
480+
.filter { theAdd -> theAdd.coordinates == theChange.coordinates }
481+
.filter { theAdd ->
482+
val theAddSourceKind = configurationNames.sourceKindFrom(theAdd.toConfiguration!!, false)?.kind
483+
theAddSourceKind == theChangeSourceKind
484+
}
485+
.filter { theAdd ->
486+
val theAddFlavor = configurationNames.findProductFlavorFrom(theAdd.toConfiguration!!)
487+
theChangeFlavor == theAddFlavor
488+
}
489+
.forEach { theAdd ->
490+
removed = true
491+
advice -= theAdd
492+
}
493+
494+
if (removed) {
495+
advice -= theChange
496+
change -= theChange
497+
}
498+
}
499+
}
500+
455501
// In some cases, a dependency might be non-transitive but still not be "declared" in a build script. For example, a
456502
// custom source set could extend another source set. In such a case, we don't want to suggest a user declare that
457503
// dependency. We can detect this by looking at the dependency graph related to the given source set.

src/main/kotlin/com/autonomousapps/model/internal/declaration/ConfigurationNames.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
// SPDX-License-Identifier: Apache-2.0
33
package com.autonomousapps.model.internal.declaration
44

5-
import com.autonomousapps.model.internal.ProjectType
5+
import com.autonomousapps.internal.android.ProductFlavor
66
import com.autonomousapps.internal.unsafeLazy
77
import com.autonomousapps.internal.utils.flatMapToOrderedSet
8+
import com.autonomousapps.model.internal.ProjectType
89
import com.autonomousapps.model.source.AndroidSourceKind
910
import com.autonomousapps.model.source.JvmSourceKind
1011
import com.autonomousapps.model.source.KmpSourceKind
@@ -14,6 +15,10 @@ import com.autonomousapps.model.source.SourceKind
1415
internal class ConfigurationNames(
1516
val projectType: ProjectType,
1617
private val supportedSourceSetNames: Set<String>,
18+
// Will be empty for non-Android projects
19+
private val buildTypes: Set<String>,
20+
// Will be empty for non-Android projects
21+
private val productFlavors: Set<ProductFlavor>,
1722
) {
1823

1924
private companion object {
@@ -134,7 +139,7 @@ internal class ConfigurationNames(
134139
* Infers a [SourceKind] from a [configurationName]. Will return null if the `sourceKind` to which the configuration
135140
* belongs is not in [supportedSourceSetNames].
136141
*/
137-
internal fun sourceKindFrom(
142+
fun sourceKindFrom(
138143
configurationName: String,
139144
hasCustomSourceSets: Boolean,
140145
): SourceKind? {
@@ -186,6 +191,13 @@ internal class ConfigurationNames(
186191
}
187192
}
188193

194+
fun findProductFlavorFrom(configurationName: String): String? {
195+
return productFlavors
196+
.filter { configurationName.startsWith(it.flavorName) }
197+
.maxByOrNull { it.flavorName.length }
198+
?.flavorName
199+
}
200+
189201
private fun findSourceKind(
190202
variantSlug: String,
191203
hasCustomSourceSets: Boolean,

src/main/kotlin/com/autonomousapps/subplugin/ProjectPlugin.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.autonomousapps.internal.UsagesExclusions
1919
import com.autonomousapps.internal.advice.DslKind
2020
import com.autonomousapps.internal.analyzer.*
2121
import com.autonomousapps.internal.android.AgpVersion
22+
import com.autonomousapps.internal.android.ProductFlavor
2223
import com.autonomousapps.internal.artifacts.DagpArtifacts
2324
import com.autonomousapps.internal.utils.addAll
2425
import com.autonomousapps.internal.utils.log
@@ -117,6 +118,9 @@ internal class ProjectPlugin(private val project: Project) {
117118
private val isDataBindingEnabled = project.objects.property(Boolean::class.java).convention(false)
118119
private val isViewBindingEnabled = project.objects.property(Boolean::class.java).convention(false)
119120

121+
private val buildTypes = project.objects.setProperty(String::class.java).convention(emptySet())
122+
private val productFlavors = project.objects.setProperty(ProductFlavor::class.java).convention(emptySet())
123+
120124
private var isAndroidProject = false
121125
private var isKmpProject = false
122126

@@ -305,6 +309,8 @@ internal class ProjectPlugin(private val project: Project) {
305309

306310
androidComponents.onVariants { variant ->
307311
if (variant.name !in ignoredVariantNames) {
312+
rememberVariantParts(variant)
313+
308314
val mainSourceSets = variant.sources
309315

310316
val unitTest = if (shouldAnalyzeTests() && variant is HasUnitTest) {
@@ -405,6 +411,8 @@ internal class ProjectPlugin(private val project: Project) {
405411

406412
androidComponents.onVariants { variant ->
407413
if (variant.name !in ignoredVariantNames) {
414+
rememberVariantParts(variant)
415+
408416
val mainSourceSets = variant.sources
409417

410418
val unitTest = if (shouldAnalyzeTests() && variant is HasUnitTest) {
@@ -509,6 +517,8 @@ internal class ProjectPlugin(private val project: Project) {
509517

510518
androidComponents.onVariants { variant ->
511519
if (variant.name !in ignoredVariantNames) {
520+
rememberVariantParts(variant)
521+
512522
val mainSourceSets = variant.sources
513523

514524
// nb: com.android.test projects have no test nor androidTest source.
@@ -535,6 +545,13 @@ internal class ProjectPlugin(private val project: Project) {
535545
}
536546
}
537547

548+
private fun rememberVariantParts(variant: ComponentIdentity) {
549+
variant.buildType?.let { buildTypes.add(it) }
550+
variant.productFlavors.forEach { (dimension, flavorName) ->
551+
productFlavors.add(ProductFlavor(dimension, flavorName))
552+
}
553+
}
554+
538555
private fun newVariantSourceSet(
539556
sourceKind: AndroidSourceKind,
540557
variant: Variant,
@@ -1064,8 +1081,13 @@ internal class ProjectPlugin(private val project: Project) {
10641081
t.dependencyGraphViews.add(graphViewTask.flatMap { it.output })
10651082
t.dependencyGraphViews.add(graphViewTask.flatMap { it.outputRuntime })
10661083
t.dependencyUsageReports.add(computeUsagesTask.flatMap { it.output })
1084+
1085+
// Android stuff
10671086
androidScoreTask?.let { a -> t.androidScoreReports.add(a.flatMap { it.output }) }
1087+
t.buildTypes.set(buildTypes)
1088+
t.productFlavors.set(productFlavors)
10681089
}
1090+
10691091
filterAdviceTask.configure { t ->
10701092
t.buildPath.set(buildPath(dependencyAnalyzer.compileConfigurationName))
10711093
t.dependencyGraphViews.add(graphViewTask.flatMap { it.output })
@@ -1088,6 +1110,8 @@ internal class ProjectPlugin(private val project: Project) {
10881110
project = project,
10891111
projectType = projectType,
10901112
supportedSourceSetNames = supportedSourceSetNames,
1113+
buildTypes = buildTypes,
1114+
productFlavors = productFlavors,
10911115
outputPaths = paths
10921116
)
10931117
}

0 commit comments

Comments
 (0)