1 /*
<lambda>null2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.baselineprofile.gradle.consumer.task
18 
19 import androidx.baselineprofile.gradle.consumer.RuleType
20 import androidx.baselineprofile.gradle.utils.BaselineProfilePluginLogger
21 import androidx.baselineprofile.gradle.utils.TASK_NAME_SUFFIX
22 import androidx.baselineprofile.gradle.utils.Warnings
23 import androidx.baselineprofile.gradle.utils.maybeRegister
24 import com.android.build.gradle.internal.tasks.BuildAnalyzer
25 import com.android.buildanalyzer.common.TaskCategory
26 import java.io.File
27 import kotlin.io.path.Path
28 import org.gradle.api.DefaultTask
29 import org.gradle.api.GradleException
30 import org.gradle.api.Project
31 import org.gradle.api.file.ConfigurableFileCollection
32 import org.gradle.api.file.Directory
33 import org.gradle.api.file.DirectoryProperty
34 import org.gradle.api.file.FileCollection
35 import org.gradle.api.provider.ListProperty
36 import org.gradle.api.provider.Property
37 import org.gradle.api.provider.Provider
38 import org.gradle.api.tasks.CacheableTask
39 import org.gradle.api.tasks.Input
40 import org.gradle.api.tasks.InputFiles
41 import org.gradle.api.tasks.Optional
42 import org.gradle.api.tasks.OutputDirectory
43 import org.gradle.api.tasks.PathSensitive
44 import org.gradle.api.tasks.PathSensitivity
45 import org.gradle.api.tasks.TaskAction
46 import org.gradle.api.tasks.TaskProvider
47 
48 /**
49  * Collects all the baseline profile artifacts generated by all the producer configurations and
50  * merges them into one, sorting and ensuring that there are no duplicated lines.
51  *
52  * The format of the profile is a simple list of classes and methods loaded in memory when executing
53  * a test, expressed in JVM format. Duplicates can arise when multiple tests cover the same code:
54  * for example when having 2 tests both covering the startup path and then doing something else,
55  * both will have startup classes and methods. There is no harm in having this duplication but
56  * mostly the profile file will be unnecessarily larger.
57  */
58 @CacheableTask
59 @BuildAnalyzer(primaryTaskCategory = TaskCategory.OPTIMIZATION)
60 abstract class MergeBaselineProfileTask : DefaultTask() {
61 
62     companion object {
63 
64         private const val MERGE_TASK_NAME = "merge"
65         private const val COPY_TASK_NAME = "copy"
66 
67         // Filename parts to differentiate how to use the profile rules
68         private const val FILENAME_MATCHER_BASELINE_PROFILE = "baseline-prof"
69         private const val FILENAME_MATCHER_STARTUP_PROFILE = "startup-prof"
70 
71         // The output file for the HRF baseline profile file in `src/main`
72         private const val BASELINE_PROFILE_FILENAME = "baseline-prof.txt"
73         private const val STARTUP_PROFILE_FILENAME = "startup-prof.txt"
74 
75         internal fun maybeRegisterForMerge(
76             project: Project,
77             variantName: String,
78             mergeAwareTaskName: String,
79             hasDependencies: Boolean,
80             library: Boolean,
81             sourceProfilesFileCollection: FileCollection,
82             outputDir: Provider<Directory>,
83             filterRules: List<Pair<RuleType, String>> = listOf(),
84             isLastTask: Boolean,
85             warnings: Warnings
86         ): TaskProvider<MergeBaselineProfileTask> {
87             return project.tasks.maybeRegister(
88                 MERGE_TASK_NAME,
89                 mergeAwareTaskName,
90                 TASK_NAME_SUFFIX
91             ) { task ->
92 
93                 // Sets whether or not baseline profile dependencies have been set.
94                 // If they haven't, the task will fail at execution time.
95                 task.hasDependencies.set(hasDependencies)
96 
97                 // Sets the name of this variant to print it in error messages.
98                 task.variantName.set(variantName)
99 
100                 // These are all the configurations this task depends on,
101                 // in order to consume their artifacts. Note that if this task already
102                 // exist (for example if `merge` is `all`) the new artifact will be
103                 // added to the existing list.
104                 task.baselineProfileFileCollection.from.add(sourceProfilesFileCollection)
105 
106                 // This is the task output for the generated baseline profile. Output
107                 // is always stored in the intermediates
108                 task.baselineProfileDir.set(outputDir)
109 
110                 // Sets the package filter rules. Note that if this task already exists
111                 // because of a mergeIntoMain rule, rules are added to the existing ones.
112                 task.filterRules.addAll(filterRules)
113 
114                 // Sets whether this task has been configured for a library. In this case,
115                 // startup profiles are not handled.
116                 task.library.set(library)
117 
118                 // Determines whether this is the last task to be executed. This flag is used
119                 // exclusively for logging purposes.
120                 task.lastTask.set(isLastTask)
121 
122                 // Determines whether this task should print warnings. Note that warnings used
123                 // by Android Studio cannot be suppressed.
124                 task.printWarningNoBaselineProfileRulesGenerated.set(
125                     warnings.noBaselineProfileRulesGenerated
126                 )
127                 task.printWarningNoStartupProfileRulesGenerated.set(
128                     warnings.noStartupProfileRulesGenerated
129                 )
130                 task.printWarningVariantHasNoBaselineProfileDependency.set(
131                     warnings.variantHasNoBaselineProfileDependency
132                 )
133             }
134         }
135 
136         internal fun maybeRegisterForCopy(
137             project: Project,
138             variantName: String,
139             mergeAwareTaskName: String,
140             library: Boolean,
141             sourceDir: Provider<Directory>,
142             outputDir: Provider<Directory>,
143             isLastTask: Boolean,
144             hasDependencies: Boolean,
145             warnings: Warnings
146         ): TaskProvider<MergeBaselineProfileTask> {
147             return project.tasks.maybeRegister(
148                 COPY_TASK_NAME,
149                 mergeAwareTaskName,
150                 "baselineProfileIntoSrc"
151             ) { task ->
152                 // For explanation about each of these properties, see above function named
153                 // `maybeRegisterForMerge`.
154                 task.baselineProfileFileCollection.from.add(sourceDir)
155                 task.baselineProfileDir.set(outputDir)
156                 task.library.set(library)
157                 task.variantName.set(variantName)
158                 task.lastTask.set(isLastTask)
159                 task.hasDependencies.set(hasDependencies)
160                 task.printWarningNoBaselineProfileRulesGenerated.set(
161                     warnings.noBaselineProfileRulesGenerated
162                 )
163                 task.printWarningNoStartupProfileRulesGenerated.set(
164                     warnings.noStartupProfileRulesGenerated
165                 )
166                 task.printWarningVariantHasNoBaselineProfileDependency.set(
167                     warnings.variantHasNoBaselineProfileDependency
168                 )
169             }
170         }
171     }
172 
173     @get:Input abstract val variantName: Property<String>
174 
175     @get:Input abstract val lastTask: Property<Boolean>
176 
177     @get:Input @get:Optional abstract val hasDependencies: Property<Boolean>
178 
179     @get:Input abstract val library: Property<Boolean>
180 
181     @get:InputFiles
182     @get:PathSensitive(PathSensitivity.NONE)
183     abstract val baselineProfileFileCollection: ConfigurableFileCollection
184 
185     @get:Input abstract val filterRules: ListProperty<Pair<RuleType, String>>
186 
187     @get:OutputDirectory abstract val baselineProfileDir: DirectoryProperty
188 
189     @get:Input abstract val printWarningNoBaselineProfileRulesGenerated: Property<Boolean>
190 
191     @get:Input abstract val printWarningNoStartupProfileRulesGenerated: Property<Boolean>
192 
193     @get:Input abstract val printWarningVariantHasNoBaselineProfileDependency: Property<Boolean>
194 
195     private val logger by lazy { BaselineProfilePluginLogger(this.getLogger()) }
196 
197     private val variantHasDependencies by lazy {
198         hasDependencies.isPresent && hasDependencies.get()
199     }
200 
201     @TaskAction
202     fun exec() {
203 
204         // This warning should be printed only if no dependency has been set for the processed
205         // variant.
206         if (lastTask.get() && !variantHasDependencies) {
207             logger.warn(
208                 property = { printWarningVariantHasNoBaselineProfileDependency.get() },
209                 propertyName = "variantHasNoBaselineProfileDependency",
210                 message =
211                     """
212                 The baseline profile consumer plugin is applied to this module but no dependency
213                 has been set for variant `${variantName.get()}`, so no baseline profile will be
214                 generated for it.
215 
216                 A dependency for all the variants can be added in the dependency block using
217                 `baselineProfile` configuration:
218 
219                 dependencies {
220                     ...
221                     baselineProfile(project(":baselineprofile"))
222                 }
223 
224                 Or for a specific variant in the baseline profile block:
225 
226                 baselineProfile {
227                     variants {
228                         freeRelease {
229                             from(project(":baselineprofile"))
230                         }
231                     }
232                 }
233                 """
234                         .trimIndent()
235             )
236         }
237 
238         // Rules are sorted for package depth and excludes are always evaluated first.
239         val rules =
240             filterRules
241                 .get()
242                 .sortedWith(
243                     compareBy<Pair<RuleType, String>> { r -> r.second.split(".").size }
244                         .thenComparing { r -> if (r.first == RuleType.INCLUDE) 0 else 1 }
245                         .reversed()
246                 )
247 
248         // Read the profile rules from the file collection that contains the profile artifacts from
249         // all the configurations for this variant and merge them in a single list.
250         val baselineProfileRules =
251             baselineProfileFileCollection.files.readLines {
252                 FILENAME_MATCHER_BASELINE_PROFILE in it.name ||
253                     FILENAME_MATCHER_STARTUP_PROFILE in it.name
254             }
255 
256         // This warning should be printed only if the variant has dependencies but there are no
257         // baseline profile rules.
258         if (lastTask.get() && variantHasDependencies && baselineProfileRules.isEmpty()) {
259             logger.warn(
260                 property = { printWarningNoBaselineProfileRulesGenerated.get() },
261                 propertyName = "noBaselineProfileRulesGenerated",
262                 message =
263                     """
264                 No baseline profile rules were generated for the variant `${variantName.get()}`.
265                 This is most likely because there are no instrumentation test for it. If this
266                 is not intentional check that tests for this variant exist in the `baselineProfile`
267                 dependency module.
268             """
269                         .trimIndent()
270             )
271         }
272 
273         // The profile rules here are:
274         // - sorted (since we group by class later, we want the input to the group by operation not
275         //      to be influenced by reading order)
276         // - group by class and method (ignoring flag) and for each group keep only the first value
277         // - apply the filters
278         // - sort with comparator
279         val filteredBaselineProfileRules =
280             baselineProfileRules
281                 .sorted()
282                 .asSequence()
283                 .mapNotNull { ProfileRule.parse(it) }
284                 .groupBy { it.classDescriptor + it.methodDescriptor }
285                 .map { it.value[0] }
286                 .filter {
287 
288                     // If no rules are specified, always include this line.
289                     if (rules.isEmpty()) return@filter true
290 
291                     // Otherwise rules are evaluated in the order they've been sorted previously.
292                     for (r in rules) {
293                         if (r.matches(it.fullClassName)) {
294                             return@filter r.isInclude()
295                         }
296                     }
297 
298                     // If the rules were all excludes and nothing matched, we can include this line
299                     // otherwise exclude it.
300                     return@filter !rules.any { r -> r.isInclude() }
301                 }
302                 .sortedWith(ProfileRule.comparator)
303 
304         // Check if the filters filtered out all the rules.
305         if (
306             baselineProfileRules.isNotEmpty() &&
307                 filteredBaselineProfileRules.isEmpty() &&
308                 rules.isNotEmpty()
309         ) {
310             throw GradleException(
311                 """
312                 The baseline profile consumer plugin is configured with filters that exclude all
313                 the profile rules. Please review your build.gradle configuration and make sure your
314                 filters don't exclude all the baseline profile rules.
315             """
316                     .trimIndent()
317             )
318         }
319 
320         writeProfile(
321             filename = BASELINE_PROFILE_FILENAME,
322             rules = filteredBaselineProfileRules,
323             profileType = "baseline"
324         )
325 
326         // If this is a library we can stop here and don't manage the startup profiles.
327         if (library.get()) {
328             return
329         }
330 
331         // Same process with startup profiles.
332         val startupRules =
333             baselineProfileFileCollection.files.readLines {
334                 FILENAME_MATCHER_STARTUP_PROFILE in it.name
335             }
336 
337         // This warning should be printed only if the variant has dependencies but there are no
338         // startup profile rules.
339         if (lastTask.get() && variantHasDependencies && startupRules.isEmpty()) {
340             logger.warn(
341                 property = { printWarningNoStartupProfileRulesGenerated.get() },
342                 propertyName = "noBaselineProfileRulesGenerated",
343                 message =
344                     """
345                 No startup profile rules were generated for the variant `${variantName.get()}`.
346                 This is most likely because there are no instrumentation test with baseline profile
347                 rule, which specify `includeInStartupProfile = true`. If this is not intentional
348                 check that tests for this variant exist in the `baselineProfile` dependency module.
349             """
350                         .trimIndent()
351             )
352         }
353 
354         // Use same sorting without filter for startup profiles.
355         val sortedStartupProfileRules =
356             startupRules
357                 .asSequence()
358                 .sorted()
359                 .mapNotNull { ProfileRule.parse(it) }
360                 .groupBy { it.classDescriptor + it.methodDescriptor }
361                 .map { it.value[0] }
362                 .sortedWith(ProfileRule.comparator)
363 
364         writeProfile(
365             filename = STARTUP_PROFILE_FILENAME,
366             profileType = "startup",
367             rules = sortedStartupProfileRules,
368         )
369     }
370 
371     private fun writeProfile(filename: String, rules: List<ProfileRule>, profileType: String) {
372         baselineProfileDir.file(filename).get().asFile.apply {
373 
374             // If an old profile file already exists calculate stats.
375             val stats =
376                 if (exists())
377                     ProfileStats.from(
378                         existingRules = readLines().mapNotNull { ProfileRule.parse(it) },
379                         newRules = rules
380                     )
381                 else null
382 
383             delete()
384             if (rules.isEmpty()) return
385             writeText(rules.joinToString(System.lineSeparator()) { it.underlying })
386 
387             // If this is the last task display a success message (depending on the flag
388             // `saveInSrc` this task may be configured as a merge or copy task).
389             if (!lastTask.get()) {
390                 return
391             }
392 
393             // This log should not be suppressed because it's used by Android Studio to
394             // open the generated HRF file.
395             logger.warn(
396                 property = { true },
397                 propertyName = null,
398                 message =
399                     """
400 
401                     A $profileType profile was generated for the variant `${variantName.get()}`:
402                     ${Path(absolutePath).toUri()}
403                         """
404                         .trimIndent()
405             )
406 
407             // Print stats if was previously calculated
408             stats?.apply {
409                 logger.warn(
410                     property = { true },
411                     propertyName = null,
412                     message =
413                         """
414 
415                     Comparison with previous $profileType profile:
416                       $existing Old rules
417                       $new New rules
418                       $added Added rules (${"%.2f".format(addedRatio * 100)}%)
419                       $removed Removed rules (${"%.2f".format(removedRatio * 100)}%)
420                       $unmodified Unmodified rules (${"%.2f".format(unmodifiedRatio * 100)}%)
421 
422                       """
423                             .trimIndent()
424                 )
425             }
426         }
427     }
428 
429     private fun Pair<RuleType, String>.isInclude(): Boolean = first == RuleType.INCLUDE
430 
431     private fun Pair<RuleType, String>.matches(fullClassName: String): Boolean {
432         val rule = second
433         return when {
434             rule.endsWith(".**") -> {
435                 // This matches package and subpackages
436                 val pkg = fullClassName.split(".").dropLast(1).joinToString(".")
437                 val rulePkg = rule.dropLast(3)
438                 pkg.startsWith(rulePkg)
439             }
440             rule.endsWith(".*") -> {
441                 // This matches only the package
442                 val pkgParts = fullClassName.split(".").dropLast(1)
443                 val pkg = pkgParts.joinToString(".")
444                 val rulePkg = rule.dropLast(2)
445                 val ruleParts = rulePkg.split(".")
446                 pkg.startsWith(rulePkg) && ruleParts.size == pkgParts.size
447             }
448             else -> {
449                 // This matches only the specific class name
450                 fullClassName == rule
451             }
452         }
453     }
454 
455     private fun Iterable<File>.readLines(filterBlock: (File) -> (Boolean)): List<String> =
456         this.flatMap {
457                 if (it.isFile) {
458                     listOf(it)
459                 } else {
460                     listOf(*(it.listFiles() ?: arrayOf()))
461                 }
462             }
463             .filter(filterBlock)
464             .flatMap { it.readLines() }
465 }
466 
467 internal data class ProfileStats(
468     val existing: Int,
469     val new: Int,
470     val added: Int,
471     val removed: Int,
472     val unmodified: Int,
473     val addedRatio: Float,
474     val removedRatio: Float,
475     val unmodifiedRatio: Float,
476 ) {
477     companion object {
fromnull478         fun from(existingRules: List<ProfileRule>, newRules: List<ProfileRule>): ProfileStats {
479             val existingRulesSet =
480                 existingRules.map { "${it.classDescriptor}:${it.methodDescriptor}" }.toHashSet()
481             val newRulesSet =
482                 newRules.map { "${it.classDescriptor}:${it.methodDescriptor}" }.toHashSet()
483             val allUniqueRules = existingRulesSet.union(newRulesSet).size
484 
485             var unmodified = 0
486             var added = 0
487             var removed = 0
488 
489             for (x in existingRulesSet) {
490                 if (x in newRulesSet) {
491                     unmodified++
492                 } else {
493                     removed++
494                 }
495             }
496             for (x in newRulesSet) {
497                 if (x !in existingRulesSet) {
498                     added++
499                 }
500             }
501 
502             return ProfileStats(
503                 existing = existingRulesSet.size,
504                 new = newRulesSet.size,
505                 unmodified = unmodified,
506                 added = added,
507                 removed = removed,
508                 addedRatio = added.toFloat() / allUniqueRules,
509                 removedRatio = removed.toFloat() / allUniqueRules,
510                 unmodifiedRatio = unmodified.toFloat() / allUniqueRules
511             )
512         }
513     }
514 }
515