Skip to content

Commit 002ad35

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

8 files changed

Lines changed: 308 additions & 9 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: 12 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,8 @@ import com.autonomousapps.model.source.SourceKind
1415
internal class ConfigurationNames(
1516
val projectType: ProjectType,
1617
private val supportedSourceSetNames: Set<String>,
18+
private val buildTypes: Set<String>,
19+
private val productFlavors: Set<ProductFlavor>,
1720
) {
1821

1922
private companion object {
@@ -134,7 +137,7 @@ internal class ConfigurationNames(
134137
* Infers a [SourceKind] from a [configurationName]. Will return null if the `sourceKind` to which the configuration
135138
* belongs is not in [supportedSourceSetNames].
136139
*/
137-
internal fun sourceKindFrom(
140+
fun sourceKindFrom(
138141
configurationName: String,
139142
hasCustomSourceSets: Boolean,
140143
): SourceKind? {
@@ -186,6 +189,13 @@ internal class ConfigurationNames(
186189
}
187190
}
188191

192+
fun findProductFlavorFrom(configurationName: String): String? {
193+
return productFlavors
194+
.filter { configurationName.startsWith(it.flavorName) }
195+
.maxByOrNull { it.flavorName.length }
196+
?.flavorName
197+
}
198+
189199
private fun findSourceKind(
190200
variantSlug: String,
191201
hasCustomSourceSets: Boolean,

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

Lines changed: 23 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,12 @@ internal class ProjectPlugin(private val project: Project) {
305309

306310
androidComponents.onVariants { variant ->
307311
if (variant.name !in ignoredVariantNames) {
312+
// TODO: do for all Android plugin types
313+
variant.buildType?.let { buildTypes.add(it) }
314+
variant.productFlavors.forEach { (dimension, flavorName) ->
315+
productFlavors.add(ProductFlavor(dimension, flavorName))
316+
}
317+
308318
val mainSourceSets = variant.sources
309319

310320
val unitTest = if (shouldAnalyzeTests() && variant is HasUnitTest) {
@@ -405,6 +415,12 @@ internal class ProjectPlugin(private val project: Project) {
405415

406416
androidComponents.onVariants { variant ->
407417
if (variant.name !in ignoredVariantNames) {
418+
// TODO: do for all Android plugin types
419+
variant.buildType?.let { buildTypes.add(it) }
420+
variant.productFlavors.forEach { (dimension, flavorName) ->
421+
productFlavors.add(ProductFlavor(dimension, flavorName))
422+
}
423+
408424
val mainSourceSets = variant.sources
409425

410426
val unitTest = if (shouldAnalyzeTests() && variant is HasUnitTest) {
@@ -1064,8 +1080,13 @@ internal class ProjectPlugin(private val project: Project) {
10641080
t.dependencyGraphViews.add(graphViewTask.flatMap { it.output })
10651081
t.dependencyGraphViews.add(graphViewTask.flatMap { it.outputRuntime })
10661082
t.dependencyUsageReports.add(computeUsagesTask.flatMap { it.output })
1083+
1084+
// Android stuff
10671085
androidScoreTask?.let { a -> t.androidScoreReports.add(a.flatMap { it.output }) }
1086+
t.buildTypes.set(buildTypes)
1087+
t.productFlavors.set(productFlavors)
10681088
}
1089+
10691090
filterAdviceTask.configure { t ->
10701091
t.buildPath.set(buildPath(dependencyAnalyzer.compileConfigurationName))
10711092
t.dependencyGraphViews.add(graphViewTask.flatMap { it.output })
@@ -1088,6 +1109,8 @@ internal class ProjectPlugin(private val project: Project) {
10881109
project = project,
10891110
projectType = projectType,
10901111
supportedSourceSetNames = supportedSourceSetNames,
1112+
buildTypes = buildTypes,
1113+
productFlavors = productFlavors,
10911114
outputPaths = paths
10921115
)
10931116
}

0 commit comments

Comments
 (0)