Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/header-checker-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ sourceFileExtensions:
- 'kts'
- 'xml'
- 'gradle'
ignoreFiles:
# Baseline Profiles are generated by the :baselineprofile module and overwritten on every run.
- '**/src/main/generated/baselineProfiles/baseline-prof.txt'
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ Without a Maps API key in a valid format the demos close themselves and
`-Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true` to also
wait for map tiles to render, which needs a real key.

The library modules ship Baseline Profiles in
`src/main/generated/baselineProfiles/baseline-prof.txt`. They are generated by
the `:baselineprofile` module, which runs the demos on a device, and
`BaselineProfileConventionPlugin` in `build-logic` keeps only each module's own
classes. Regenerate them after changing a hot path (polyline utilities,
clustering, heatmaps, GeoJSON/KML layers, IconGenerator), with an emulator or
device attached and a real API key (the demos close themselves without one):

```bash
./gradlew :library:generateBaselineProfile :clustering:generateBaselineProfile \
:heatmaps:generateBaselineProfile :data:generateBaselineProfile \
:ui:generateBaselineProfile
```

Running the `demo` app requires a Maps API key in `secrets.properties` at the
repo root (see `local.defaults.properties` for the template). Never hardcode
or commit API keys.
Expand Down
62 changes: 62 additions & 0 deletions baselineprofile/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

// Generates the Baseline Profiles shipped with the library modules by running the demo app on a
// device. See BaselineProfileGenerator.
plugins {
id("com.android.test")
id("androidx.baselineprofile")
}

android {
namespace = "com.google.maps.android.utils.baselineprofile"
compileSdk = libs.versions.compileSdk.get().toInt()

defaultConfig {
// Macrobenchmark and the Baseline Profile rule need API 28.
minSdk = 28
targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// The demo app has a navigation flavor too; the profiles come from the standard one.
missingDimensionStrategy("sdk", "standard")
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

targetProjectPath = ":demo"
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

baselineProfile {
// Run on the attached device or emulator. It needs a real MAPS_API_KEY so the maps load.
useConnectedDevices = true
}

dependencies {
implementation(libs.androidx.test.ext.junit)
implementation(libs.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}
16 changes: 16 additions & 0 deletions baselineprofile/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?><!--
Copyright 2026 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.maps.android.utils.baselineprofile

import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Generates the Baseline Profiles of the library modules by running the demos that exercise them:
* polyline encoding, simplification and distances (library), IconGenerator (ui), clustering,
* heatmaps and GeoJSON and KML layers (data).
*
* Run it with an emulator or device attached and a real MAPS_API_KEY in secrets.properties:
* ```
* ./gradlew :library:generateBaselineProfile :clustering:generateBaselineProfile \
* :heatmaps:generateBaselineProfile :data:generateBaselineProfile :ui:generateBaselineProfile
* ```
* Each module keeps only the rules for its own classes (see BaselineProfileConventionPlugin in
* build-logic) and the result is written to its `src/main/generated/baselineProfiles/baseline-prof.txt`,
* which is packaged in the AAR.
*/
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()

@Test
fun generate() {
rule.collect(packageName = TARGET_PACKAGE) {
pressHome()
startActivityAndWait()
DEMOS.forEach { demo -> exploreDemo(demo) }
}
}
}

/** Demos that cover the public API of library, ui, clustering, heatmaps and data. */
private val DEMOS = listOf(
"PolyDecodeDemoActivity",
"PolySimplifyDemoActivity",
"FlexiblePolylineDemoActivity",
"DistanceDemoActivity",
"KtxExtensionsDemoActivity",
"IconGeneratorDemoActivity",
"ClusteringDemoActivity",
"BigClusteringDemoActivity",
"CustomMarkerClusteringDemoActivity",
"ZoomClusteringDemoActivity",
"HeatmapsDemoActivity",
"GeoJsonDemoActivity",
"KmlDemoActivity",
"MultiLayerDemoActivity",
)

/**
* Opens [demo], waits for the map to load and pans and zooms it, so camera changes, re-clustering
* and layer rendering are part of the profile.
*/
internal fun MacrobenchmarkScope.exploreDemo(demo: String) {
// startActivityAndWait relies on frame stats that the map's SurfaceView does not always
// report, so the demos are started with `am start -W`, which waits for the launch instead.
device.executeShellCommand("am start -W -n $packageName/$DEMO_PACKAGE.$demo")
// The map loads asynchronously after the first frame, and there is no view to wait for.
Thread.sleep(MAP_LOAD_DELAY_MS)

val centerX = device.displayWidth / 2
val centerY = device.displayHeight / 2
val distance = device.displayWidth / 4
device.swipe(centerX + distance, centerY, centerX - distance, centerY, SWIPE_STEPS)
device.waitForIdle()
device.swipe(centerX, centerY + distance, centerX, centerY - distance, SWIPE_STEPS)
device.waitForIdle()
// Double tap zooms in.
device.click(centerX, centerY)
device.click(centerX, centerY)
Thread.sleep(CAMERA_ANIMATION_DELAY_MS)

device.pressBack()
device.waitForIdle()
}

internal const val TARGET_PACKAGE = "com.google.maps.android.utils.demo"
private const val DEMO_PACKAGE = "com.google.maps.android.utils.demo"

private const val MAP_LOAD_DELAY_MS = 3_000L
private const val CAMERA_ANIMATION_DELAY_MS = 1_000L
private const val SWIPE_STEPS = 20
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.maps.android.utils.baselineprofile

import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Measures the effect of the Baseline Profiles: the clustering and heatmaps demos without any
* ahead-of-time compilation, and with the Baseline Profiles of the libraries applied.
*
* Run it on a physical device for meaningful numbers:
* ```
* ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest
* ```
*/
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()

@Test
fun demosCompilationNone() = demos(CompilationMode.None())

@Test
fun demosCompilationBaselineProfiles() = demos(CompilationMode.Partial(BaselineProfileMode.Require))

private fun demos(compilationMode: CompilationMode) = rule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = listOf(FrameTimingMetric()),
compilationMode = compilationMode,
startupMode = StartupMode.COLD,
iterations = ITERATIONS,
setupBlock = { pressHome() },
) {
exploreDemo("BigClusteringDemoActivity")
exploreDemo("HeatmapsDemoActivity")
}
}

private const val ITERATIONS = 5
5 changes: 5 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repositories {
dependencies {
implementation(libs.kotlin.gradle.plugin)
implementation(libs.gradle)
implementation(libs.baselineprofile.gradle.plugin)
implementation(libs.dokka.gradle.plugin)
implementation(libs.kover.gradle.plugin)
implementation(libs.gradle.maven.publish.plugin)
Expand All @@ -39,6 +40,10 @@ gradlePlugin {
id = "android.maps.utils.PublishingConventionPlugin"
implementationClass = "PublishingConventionPlugin"
}
register("baselineProfileConventionPlugin") {
id = "android.maps.utils.BaselineProfileConventionPlugin"
implementationClass = "BaselineProfileConventionPlugin"
}
register("bomPublishingConventionPlugin") {
id = "android.maps.utils.BomPublishingConventionPlugin"
implementationClass = "BomPublishingConventionPlugin"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import androidx.baselineprofile.gradle.consumer.BaselineProfileConsumerExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import java.io.File

/**
* Ships a Baseline Profile in the library's AAR. The profile is generated by the :baselineprofile
* module from the demo app, and each library keeps only the rules for its own classes.
*/
class BaselineProfileConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.run {
apply(plugin = "androidx.baselineprofile")

dependencies {
add("baselineProfile", project(":baselineprofile"))
}

configure<BaselineProfileConsumerExtension> {
filter { include("com.google.maps.android.**") }
}

keepOnlyOwnClasses()
}
}

/**
* The libraries share packages with each other and with the demo app (for example
* com.google.maps.android.ktx.utils), so the package filter above can't tell them apart. After
* the profile is copied into src, drop every rule whose class this module does not compile.
*/
private fun Project.keepOnlyOwnClasses() {
val classesDirs = listOf(
"intermediates/built_in_kotlinc/release/compileReleaseKotlin/classes",
"intermediates/javac/release/compileReleaseJavaWithJavac/classes",
).map { layout.buildDirectory.dir(it) }
val profile = layout.projectDirectory.file("src/main/generated/baselineProfiles/baseline-prof.txt").asFile

tasks.matching { it.name == "copyBaselineProfileIntoSrc" }.configureEach {
dependsOn("compileReleaseKotlin", "compileReleaseJavaWithJavac")
doLast {
val roots = classesDirs.map { it.get().asFile }
fun isOwnClass(name: String) = roots.any { File(it, "$name.class").exists() }

val kept = profile.readLines().filter { line ->
val className = Regex("L([^;]+);").find(line)?.groupValues?.get(1) ?: return@filter true
// D8 adds classes that kotlinc and javac never write: Foo$$ExternalSyntheticLambda0
// for lambdas and Foo$-CC for the default methods of interface Foo.
val ownerClass = className.substringBefore("\$\$ExternalSynthetic").removeSuffix("\$-CC")
isOwnClass(className) || isOwnClass(ownerClass)
}
profile.writeText(kept.joinToString(separator = "\n", postfix = "\n"))
}
}
}
}
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ buildscript {

dependencies {
classpath(libs.gradle)
classpath(libs.baselineprofile.gradle.plugin)
classpath(libs.kover.gradle.plugin)
classpath(libs.secrets.gradle.plugin)
classpath(libs.kotlin.gradle.plugin)
Expand Down
1 change: 1 addition & 0 deletions clustering/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ plugins {

id("org.jetbrains.dokka")
id("android.maps.utils.PublishingConventionPlugin")
id("android.maps.utils.BaselineProfileConventionPlugin")
}

android {
Expand Down
Loading
Loading